oficina / sheets Public

feat: a view layer — rastrillo tokens, one layout, theme before first paint

The app had no view layer at all: two pages assembled from Go string
constants. This is the shell everything else in plan 2 renders into.

tokens.css, theme.css and rastrillo.js are copied from the pinned
v0.22.0 and verified by `rastrillo doctor`, which reports all three
matching. select.js and datetime.js are deliberately absent — doctor
names deleting an unused one as supported, and they are 71KB this app
does not use.

The layout is Sheets' own rather than the framework's topbar, for one
reason: the grid must fill the viewport, and the framework shells wrap
content in a padded, max-width <main rst-page>. Every attribute in it
is rastrillo's own, so the chrome still matches the suite.

The theme is read from a cookie server-side and written into the first
bytes of the response. A page that fetches its theme flashes, and cell
fills resolve against the theme, so the flash repaints every coloured
cell. TestThemeIsInTheFirstResponseNotFetched fails the moment somebody
moves that into JavaScript.

Pages are discovered from the embedded directory rather than listed in
render.go: a hand-kept list is a second place to remember, and
forgetting it gives a page that silently never renders.

page_test's assertions moved from four sentences of prose to
affordances. Every one of those sentences had to be edited the day the
page became a template, which taught nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RN4HhHR4rHH3Fxh4LMds5
Paul Campbell pushed by paul@keymail.dev e239556edb0cf931e36db40489c3b365ee3bd6b5 parent 3b3cfe2
17 files changed, +2832 −109
  • internal/sheets/app.go +10 −0
  • internal/sheets/begin_test.go +10 −1
  • internal/sheets/helpers_test.go +36 −0
  • internal/sheets/page.go +5 −58
  • internal/sheets/page_test.go +6 −3
  • internal/sheets/render.go +149 −0
  • internal/sheets/render_test.go +70 −0
  • internal/sheets/signin.go +21 −47
  • internal/sheets/static/rastrillo.js +337 −0
  • internal/sheets/static/sheets.css +128 −0
  • internal/sheets/static/theme.css +174 −0
  • internal/sheets/static/tokens.css +1550 −0
  • internal/sheets/templates/index.html +28 −0
  • internal/sheets/templates/layout.html +61 −0
  • internal/sheets/templates/signin.html +34 −0
  • internal/sheets/theme.go +94 −0
  • internal/sheets/theme_test.go +119 −0
diff --git a/internal/sheets/app.go b/internal/sheets/app.go
index ef73d87..13f2e5d 100644
--- a/internal/sheets/app.go
+++ b/internal/sheets/app.go
@@ -55,6 +55,16 @@ func (a *App) Router() http.Handler {
// should see (TestIndexIsNotACatchAll pins this).
r.Get("/", a.handleIndex)
r.Get("/signin", a.handleSignin)
+ // Fingerprinted, immutably cached, and mounted at the FS's own
+ // path with no prefix stripped — rastrillo's Assets uses
+ // http.FileServerFS semantics, where the URL path is "/" plus the
+ // FS path.
+ r.Handle("/static/*", staticHandler())
+ // The theme is a per-person preference, set by a form so it works
+ // with scripting off (AGENTS.md: light/dark is per person, and a
+ // person who needs a high-contrast theme is badly served by being
+ // told to enable JavaScript first).
+ r.Post("/theme", a.handleTheme)
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
diff --git a/internal/sheets/begin_test.go b/internal/sheets/begin_test.go
index db28bcb..6cbb4f1 100644
--- a/internal/sheets/begin_test.go
+++ b/internal/sheets/begin_test.go
@@ -12,6 +12,8 @@ import (
rdb "github.com/carlosframework/rastrillo/db"
"github.com/carlosframework/rastrillo/migrate"
+
+ "amadan.net/oficina/sheets/internal/sheets/seal"
)
// recorder is a mail.Sender that delivers nothing and remembers who it
@@ -75,7 +77,14 @@ func appWithRecorder(t *testing.T) (*App, *recorder) {
if err != nil {
t.Fatalf("NewAuth: %v", err)
}
- return New(d.G, a, nil, Admits(owners)), rec
+ // A real sealer, not nil: every test that signs in and then touches
+ // a sheet needs one, and an app that can sign you in but cannot
+ // seal a title is a shape production never has.
+ sealer, err := seal.New(make([]byte, dekBytes))
+ if err != nil {
+ t.Fatalf("seal.New: %v", err)
+ }
+ return New(d.G, a, sealer, Admits(owners)), rec
}
func postSignin(t *testing.T, app *App, address string) *httptest.ResponseRecorder {
diff --git a/internal/sheets/helpers_test.go b/internal/sheets/helpers_test.go
index 8bb1cf1..97e25f6 100644
--- a/internal/sheets/helpers_test.go
+++ b/internal/sheets/helpers_test.go
@@ -4,6 +4,7 @@ import (
"context"
"net/http"
"net/http/httptest"
+ "net/url"
"path/filepath"
"testing"
@@ -48,6 +49,41 @@ func testMember(t *testing.T, app *App) Member {
return m
}
+// 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
+// session cookies it minted.
+//
+// Driven end to end rather than faked, because a hand-minted session
+// cookie proves nothing about the flow a person actually walks, and
+// this helper is what every route behind requireSession is tested
+// through.
+func signIn(t *testing.T) (*App, []*http.Cookie) {
+ t.Helper()
+ app, mail := appWithRecorder(t)
+ 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))
+ cookies := rec.Result().Cookies()
+ if len(cookies) == 0 {
+ t.Fatalf("verify set no cookie (status %d, location %q)",
+ rec.Code, rec.Header().Get("Location"))
+ }
+ return app, cookies
+}
+
+// as attaches a session to a request.
+func as(req *http.Request, cookies []*http.Cookie) *http.Request {
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
+ return req
+}
+
// do runs one request through the app's router and hands back the
// recorder, so a test reads as one line per request.
func do(t *testing.T, app *App, req *http.Request) *httptest.ResponseRecorder {
diff --git a/internal/sheets/page.go b/internal/sheets/page.go
index b5f1292..aa018b7 100644
--- a/internal/sheets/page.go
+++ b/internal/sheets/page.go
@@ -1,9 +1,6 @@
package sheets
-import (
- "html"
- "net/http"
-)
+import "net/http"
// The root page has two states, and the signed-in one is the whole
// point: a magic link lands here, so if this page cannot say "you are
@@ -13,61 +10,11 @@ import (
// SignedInPath defaults to "/", and this page told the arriving user
// there was nothing to reach at this address.
//
-// No stylesheet is linked. oficina/estilo exists and is pinned to
-// rastrillo's day theme, but this app does not consume it and nobody has
-// decided that it should — the grid slice is where that decision belongs.
-const (
- pageHead = `<!doctype html>
-<html lang="en">
-<head>
-<meta charset="utf-8">
-<title>Oficina Sheets</title>
-</head>
-<body>
-<main>
-<h1>Oficina Sheets</h1>
-`
- pageWhat = `<p>This is the Oficina Sheets engine: sealed storage, formula
-evaluation, and XLSX import and export.</p>
-<p>It has no grid yet. That is deliberate: the spreadsheet interface is
-the next slice of work, not built here. Sign-in is real and working.</p>
-`
- pageSignedOut = `<p>You are not signed in. <a href="/signin">Sign in</a> — we email you
-a link instead of asking for a password.</p>
-`
- pageFoot = `<p>This instance also answers <code>GET /healthz</code> and
-<code>GET /api/version</code>.</p>
-<p>The design is at
-<code>docs/superpowers/specs/2026-08-30-sheets-design.md</code> in this
-repository.</p>
-</main>
-</body>
-</html>
-`
-)
-
-// handleIndex serves the root page, reflecting whether the caller holds
-// a session.
+// It was assembled from string constants until the view layer arrived;
+// it is a template now, and the reasoning above is the only part worth
+// keeping from that version.
func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- w.Write([]byte(pageHead))
- w.Write([]byte(pageWhat))
-
- switch id, ok := a.identity(r); {
- case ok:
- // The address is the person's own, shown back to them, and it
- // arrives from a verified session rather than from the request —
- // but it is still escaped, because "trusted source" is the
- // reasoning behind most injection bugs.
- w.Write([]byte("<p>Signed in as <strong>" + html.EscapeString(id) +
- "</strong>.</p>\n" +
- `<form method="post" action="/signout"><button type="submit">Sign out</button></form>` +
- "\n"))
- default:
- w.Write([]byte(pageSignedOut))
- }
-
- w.Write([]byte(pageFoot))
+ a.render(w, r, "index", "Oficina Sheets", http.StatusOK, nil)
}
// identity returns the signed-in address, if any. A nil auth (tests)
diff --git a/internal/sheets/page_test.go b/internal/sheets/page_test.go
index e0b7600..9bb5f38 100644
--- a/internal/sheets/page_test.go
+++ b/internal/sheets/page_test.go
@@ -23,12 +23,15 @@ func TestIndexServesHTML(t *testing.T) {
if ct := rec.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" {
t.Errorf("Content-Type = %q", ct)
}
+ // Affordances, not copy. This test used to assert on four
+ // sentences of prose, and every one of them had to be edited the
+ // day the page moved onto a template — which taught nothing, since
+ // the thing worth pinning is that an anonymous visitor can get in
+ // and that the page carries the viewer's theme.
for _, want := range []string{
"<h1>Oficina Sheets</h1>",
- "no grid yet",
- "/healthz",
- "2026-08-30-sheets-design.md",
`<a href="/signin">`, // an anonymous visitor must be able to get in
+ `data-theme="day"`, // resolved server-side, before first paint
} {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("body missing %q", want)
diff --git a/internal/sheets/render.go b/internal/sheets/render.go
new file mode 100644
index 0000000..4a7ddc0
--- /dev/null
+++ b/internal/sheets/render.go
@@ -0,0 +1,149 @@
+package sheets
+
+import (
+ "bytes"
+ "embed"
+ "html/template"
+ "io/fs"
+ "net/http"
+ "path"
+ "strings"
+
+ "github.com/carlosframework/rastrillo"
+ "github.com/carlosframework/rastrillo/ui"
+)
+
+// templatesFS holds this app's own pages. rastrillo's partials come
+// from ui.Templates() and are parsed alongside them.
+//
+//go:embed templates/*.html
+var templatesFS embed.FS
+
+// staticFS holds everything served under /static: rastrillo's vendored
+// tokens.css, theme.css and rastrillo.js (copied by the framework's own
+// scaffold and verified by `rastrillo doctor`), our own CSS and JS, and
+// the vendored grid.
+//
+// all: is required, not decorative — the default embed pattern skips
+// files whose names begin with _ or ., and a vendored library that
+// happens to ship one would go missing at run time and nowhere else.
+//
+//go:embed all:static
+var staticFS embed.FS
+
+// assets fingerprints static files so they can be cached forever and
+// still update on an ordinary reload: the URL carries the content hash,
+// so the browser has never cached the new one stale.
+var assets = rastrillo.NewAssets(staticFS)
+
+// pages is one parsed template tree per page.
+//
+// One tree each, rather than one tree holding everything, because every
+// page defines a block called "content" and a single tree would keep
+// only the last one parsed. This is the shape rastrillo's SKILL.md
+// documents for exactly that reason.
+var pages = parsePages()
+
+// layoutFile is the one template every page is wrapped in, and the one
+// file under templates/ that is not itself a page.
+const layoutFile = "layout.html"
+
+// parsePages builds one tree per page, discovered from the embedded
+// directory rather than from a list kept in this file.
+//
+// Discovered, because a hand-kept list is a second place to remember:
+// adding templates/foo.html and forgetting the list gives a page that
+// silently never renders, and the mistake is invisible in the diff that
+// causes it. It runs once, at startup, and panics on a broken template
+// — which is right, since a template that cannot parse cannot serve any
+// request and failing at boot is louder than failing per request.
+func parsePages() map[string]*template.Template {
+ funcs := ui.Funcs()
+ funcs["asset"] = assets.Path
+
+ files, err := fs.Glob(templatesFS, "templates/*.html")
+ if err != nil {
+ panic("sheets: reading templates: " + err.Error())
+ }
+ out := make(map[string]*template.Template, len(files))
+ for _, f := range files {
+ name := strings.TrimSuffix(path.Base(f), ".html")
+ if path.Base(f) == layoutFile {
+ continue
+ }
+ t := template.Must(template.New(name).Funcs(funcs).
+ ParseFS(ui.Templates(), "*.html"))
+ t = template.Must(t.ParseFS(templatesFS, "templates/"+layoutFile, f))
+ out[name] = t
+ }
+ return out
+}
+
+// PageData is what every template receives.
+//
+// Theme is resolved here, server-side, from a cookie — never fetched.
+// AGENTS.md: light/dark must resolve before first paint, because cell
+// fills resolve against the theme and a late preference repaints every
+// coloured cell. A page that carries its theme in its first byte cannot
+// flash; one that asks for it can.
+type PageData struct {
+ Title string
+ Theme string
+ Address string
+ SignedIn bool
+ Path string
+ // Data is the page's own payload. Named rather than embedded so a
+ // template always says .Data.Sheets and never has to guess whether
+ // a field belongs to the page or the shell.
+ Data any
+}
+
+// pageData builds the shell fields from the request.
+func (a *App) pageData(r *http.Request, title string, data any) PageData {
+ address, signedIn := a.identity(r)
+ return PageData{
+ Title: title,
+ Theme: themeFrom(r),
+ Address: address,
+ SignedIn: signedIn,
+ Path: r.URL.Path,
+ Data: data,
+ }
+}
+
+// render writes a page.
+//
+// The template executes into a buffer first, and only a complete render
+// reaches the client. Executing straight to the ResponseWriter means a
+// template error halfway through has already sent a 200 and half a
+// page, and the browser shows a truncated document with no indication
+// anything went wrong.
+func (a *App) render(w http.ResponseWriter, r *http.Request, page, title string, status int, data any) {
+ t, ok := pages[page]
+ if !ok {
+ http.Error(w, "Something went wrong.", http.StatusInternalServerError)
+ return
+ }
+ var buf bytes.Buffer
+ if err := t.ExecuteTemplate(&buf, "layout", a.pageData(r, title, data)); err != nil {
+ // No cell content, sheet title or tab name reaches this — the
+ // error goes to the client as a fixed string and the detail is
+ // dropped, because an html/template error can quote the data it
+ // was rendering (AGENTS.md / Mechanical rules).
+ http.Error(w, "Something went wrong.", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ w.Write(buf.Bytes())
+}
+
+// staticHandler serves /static/* with a long cache lifetime for
+// fingerprinted URLs. Mounted at the FS's own path, with no prefix
+// stripped: rastrillo's Assets uses http.FileServerFS semantics, where
+// the URL path is "/" plus the FS path.
+func staticHandler() http.Handler { return assets.Handler() }
+
+// staticFiles is the embedded tree, exposed for the guard test that
+// walks our JavaScript for the line cap.
+func staticFiles() fs.FS { return staticFS }
diff --git a/internal/sheets/render_test.go b/internal/sheets/render_test.go
new file mode 100644
index 0000000..452814f
--- /dev/null
+++ b/internal/sheets/render_test.go
@@ -0,0 +1,70 @@
+package sheets
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// Every page is parsed at startup. A template that references a
+// nonexistent partial or misspells a field panics there, which is loud —
+// but only if something actually parses the tree, so this asserts the
+// set is non-empty and reachable rather than trusting the package var.
+func TestEveryTemplateParses(t *testing.T) {
+ if len(pages) == 0 {
+ t.Fatal("no pages parsed; the embedded templates directory is empty or the glob is wrong")
+ }
+ for _, want := range []string{"index", "signin"} {
+ if _, ok := pages[want]; !ok {
+ t.Errorf("page %q did not parse", want)
+ }
+ }
+ if _, ok := pages["layout"]; ok {
+ t.Error("layout.html was parsed as a page; it is the shell, not a page")
+ }
+}
+
+func TestStaticAssetsAreServed(t *testing.T) {
+ app := New(nil, nil, nil, nil)
+ for _, name := range []string{
+ "/static/tokens.css",
+ "/static/theme.css",
+ "/static/rastrillo.js",
+ "/static/sheets.css",
+ } {
+ rec := do(t, app, httptest.NewRequest(http.MethodGet, name, nil))
+ if rec.Code != http.StatusOK {
+ t.Errorf("GET %s = %d, want 200", name, rec.Code)
+ }
+ if rec.Body.Len() == 0 {
+ t.Errorf("GET %s served an empty body", name)
+ }
+ }
+}
+
+// The URL carries the content hash, so a browser can cache it forever
+// and still get the new one after a deploy. A page linking the bare
+// path would be served stale for as long as the cache lived.
+func TestPagesLinkFingerprintedAssets(t *testing.T) {
+ rec := do(t, New(nil, nil, nil, nil), httptest.NewRequest(http.MethodGet, "/", nil))
+ body := rec.Body.String()
+ if strings.Contains(body, `href="/static/tokens.css"`) {
+ t.Fatal("the page links tokens.css unfingerprinted; a deploy would not bust the cache")
+ }
+ if !strings.Contains(body, "/static/tokens.") {
+ t.Fatalf("the page does not link tokens.css at all:\n%s", body)
+ }
+}
+
+// A template error halfway through a direct write has already sent a
+// 200 and half a page, and the browser shows a truncated document with
+// no sign anything went wrong.
+func TestRenderOfAnUnknownPageIs500NotAPanic(t *testing.T) {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ New(nil, nil, nil, nil).render(rec, req, "no-such-page", "T", http.StatusOK, nil)
+ if rec.Code != http.StatusInternalServerError {
+ t.Fatalf("rendering an unknown page = %d, want 500", rec.Code)
+ }
+}
diff --git a/internal/sheets/signin.go b/internal/sheets/signin.go
index 31aac98..1f7e01b 100644
--- a/internal/sheets/signin.go
+++ b/internal/sheets/signin.go
@@ -48,55 +48,29 @@ func signinMessage(q map[string][]string) string {
}
}
-// signinPage is assembled from constants only — no query value is ever
-// interpolated into it. signinMessage SELECTS one of a fixed set of
-// strings rather than echoing what it was given, so there is no path
-// from the URL into the HTML and nothing here to escape.
-const (
- signinHead = `<!doctype html>
-<html lang="en">
-<head>
-<meta charset="utf-8">
-<title>Sign in — Oficina Sheets</title>
-</head>
-<body>
-<main>
-<h1>Sign in to Oficina Sheets</h1>
-`
- signinFormOpen = `<form method="post" action="/signin">
-<label for="address">Email address</label>
-<input id="address" name="address" type="email" autocomplete="email" required>
-`
- // forceField is the affordance auth asks for. When a keymail
- // sign-in fails, auth redirects to ?force=1&err=keymail
- // specifically so this page can offer the plain-email path: Begin
- // reads a non-empty "force" and uses 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 of the loop.
- forceField = `<input type="hidden" name="force" value="1">
-`
- signinFormClose = `<button type="submit">Email me a link</button>
-</form>
-<p>We email you a link instead of asking for a password. It signs you in
-once and then stops working.</p>
-</main>
-</body>
-</html>
-`
-)
+// signinView is the sign-in page's own payload.
+//
+// Message is one of a fixed set of strings signinMessage SELECTS from
+// the query's code — never the code itself, and never anything a
+// visitor typed. There is no path from the URL into this page's HTML,
+// which is why the message needs no escaping beyond what the template
+// does anyway.
+//
+// Force carries auth's ?force=1 through the form. When a keymail
+// sign-in fails, auth redirects with it specifically so this page can
+// offer the plain-email path; without it a keymail user whose OAuth
+// fails is redirected to keymail again on every retry, with no way out
+// of the loop.
+type signinView struct {
+ Message string
+ Force bool
+}
// handleSignin serves the sign-in page.
func (a *App) handleSignin(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- w.Write([]byte(signinHead))
- if msg := signinMessage(q); msg != "" {
- w.Write([]byte("<p>" + msg + "</p>\n"))
- }
- w.Write([]byte(signinFormOpen))
- if q.Get("force") != "" {
- w.Write([]byte(forceField))
- }
- w.Write([]byte(signinFormClose))
+ a.render(w, r, "signin", "Sign in — Oficina Sheets", http.StatusOK, signinView{
+ Message: signinMessage(q),
+ Force: q.Get("force") != "",
+ })
}
diff --git a/internal/sheets/static/rastrillo.js b/internal/sheets/static/rastrillo.js
new file mode 100644
index 0000000..dc4b344
--- /dev/null
+++ b/internal/sheets/static/rastrillo.js
@@ -0,0 +1,337 @@
+/* rastrillo.js — the fragment shim. First-party, dependency-free, and
+ almost entirely inert: the polling section answers only to an opt-in
+ data attribute, and everything it enhances also works with scripts
+ disabled (a status page's <noscript> meta refresh). This file is
+ app-owned from the moment it is scaffolded — edit it like any other
+ static file.
+
+ Two sections are on by default instead, because neither has a
+ per-instance decision to make. Light dismiss for the menu idioms keys
+ off the rst-dropdown / rst-row-menu attributes: a menu that closes on
+ an outside click on one screen and not the next is worse than either
+ rule applied everywhere. The busy rule keys off nothing at all — every
+ submit button in every form that goes somewhere is covered, because
+ a button that changes something should say so while it works, and a
+ form that double-submits on a second click is a bug on every screen.
+ Delete either section to opt the whole app out of it.
+
+ Vocabulary:
+ data-poll="URL" fetch URL for an HTML fragment, replace this
+ element with it, and keep going only if the
+ replacement element itself carries data-poll
+ data-poll-every="2" seconds between polls (default 2)
+ data-poll-push="URL" optional, beside data-poll: open an
+ EventSource to URL and re-fetch the fragment
+ when the server says so, instead of on a
+ timer. Any EventSource error downgrades this
+ element to timer polling for good (once,
+ no flapping); a browser without EventSource
+ never leaves the timer path
+ data-busy="false" on a <form> or on one submit button: opt OUT
+ of the busy rule below. The rule is the
+ default, so any other value — data-busy on
+ its own included — changes nothing
+ data-busy-label="…" on the form or on the button: replacement
+ button text while it works
+
+ select.js is a sibling file following exactly these rules, kept
+ separate so this one stays small enough to read in one sitting. It
+ answers data-rst-select on a <select>, which field-select emits past
+ ten options.
+
+ Every poll carries the request header Rastrillo-Fragment: 1, which
+ marks the request as a shim poll so a handler can tell it apart from
+ direct navigation. Reserved: the framework's own handlers do not
+ read it today.
+
+ Menus: an open <details rst-dropdown>, <details rst-row-menu> or a
+ nested <details rst-menu-group> closes on an outside click and on
+ Escape, in EITHER spelling, so upgrading this file before running
+ `rastrillo markup` leaves no dead menus. Exclusivity stays native.
+
+ A polled response may answer 204 with a Rastrillo-Location header
+ instead of a fragment; the shim navigates there, but only to a local
+ path. 403 and 404 end the poll for good — the job was swept, or was
+ never yours. Any other fetch error backs off (doubling to a 30s cap)
+ and keeps trying — a network blip must not strand a status page. */
+(function () {
+ "use strict";
+
+ // The same rule sessions.SafeReturn enforces server-side: a same-site
+ // absolute path — starts with exactly one "/", no scheme, no
+ // backslash, no control characters (browsers strip tab/CR/LF before
+ // parsing, so "/\t/evil.example" would resolve scheme-relative).
+ // Anything laxer is an open redirect, here driven by a response
+ // header instead of a form field.
+ function localPath(to) {
+ return to.charAt(0) === "/" && to.charAt(1) !== "/" &&
+ to.indexOf("\\") === -1 && !/[\u0000-\u001f\u007f]/.test(to);
+ }
+
+ function poll(el) {
+ var base = (parseFloat(el.getAttribute("data-poll-every")) || 2) * 1000;
+ var wait = base;
+ var src = null; // open EventSource while pushing; null on the timer path
+ var busy = false; // a fetch is in flight
+ var queued = false; // an update landed mid-fetch; tick again after it
+ function stop() {
+ if (src) { src.close(); src = null; }
+ }
+ // One downgrade, never back: push hands this element to the timer.
+ function fallback() {
+ stop();
+ schedule();
+ }
+ function tick() {
+ if (busy) { queued = true; return; }
+ busy = true;
+ fetch(el.getAttribute("data-poll"), { headers: { "Rastrillo-Fragment": "1" } })
+ .then(function (res) {
+ // Terminal, not an error to retry: retrying a 404 forever is
+ // noise, and following a signed-out redirect would swap a
+ // sign-in page into the fragment's slot.
+ if (res.status === 403 || res.status === 404) return null;
+ if (!res.ok && res.status !== 204) throw new Error("status " + res.status);
+ var to = res.headers.get("Rastrillo-Location");
+ if (to) {
+ if (localPath(to)) window.location.assign(to);
+ return null; // navigating, or refusing to — either way, done
+ }
+ return res.text();
+ })
+ .then(function (html) {
+ busy = false;
+ if (html === null) { stop(); return; } // stopped
+ wait = base; // a healthy response resets the backoff
+ var tpl = document.createElement("template");
+ tpl.innerHTML = html;
+ var next = tpl.content.firstElementChild;
+ if (!next) { stop(); return; } // fragment with no element: stop politely
+ el.replaceWith(next);
+ el = next;
+ if (!el.hasAttribute("data-poll")) { stop(); return; }
+ if (!src) { schedule(); return; }
+ // Pushing: a fragment that dropped data-poll-push falls back
+ // to the timer; otherwise catch up if an update was queued.
+ if (!el.hasAttribute("data-poll-push")) { fallback(); return; }
+ if (queued) { queued = false; tick(); }
+ })
+ .catch(function () {
+ busy = false;
+ wait = Math.min(wait * 2, 30000);
+ if (!src) schedule(); // pushing: the next event retries instead
+ });
+ }
+ function schedule() { setTimeout(tick, wait); }
+ var push = el.getAttribute("data-poll-push");
+ if (push && window.EventSource) {
+ src = new EventSource(push);
+ src.addEventListener("update", function () { tick(); });
+ src.addEventListener("done", function () { stop(); tick(); });
+ src.addEventListener("gone", stop);
+ src.onerror = fallback;
+ return;
+ }
+ schedule();
+ }
+
+ // ── The busy rule ────────────────────────────────────────────────
+ //
+ // A button that CHANGES something says so while it works; a button
+ // that only reveals something — a disclosure, a dropdown, a tab —
+ // does not. So every submit button in every form is covered by
+ // default, with no attribute to remember: the submitted button gets
+ // aria-busy, a spinner and — once the submission is under way —
+ // disabled, and its form gets aria-busy and a guard against a second
+ // submit. The data-busy vocabulary above opts out of either half.
+ //
+ // The guard is the substance, the spinner is the manners, and neither
+ // is a promise: with scripts off the form submits exactly as it
+ // always did, twice if the visitor clicks twice. Idempotency stays
+ // the server's job. One delegated capture-phase listener, for the
+ // reasons light dismiss gives below: fragments, and never double-bind.
+ //
+ // Two traps this shape exists to avoid:
+ //
+ // - A form the browser REFUSED must not sit there looking busy.
+ // Constraint validation fails before the submit event is fired at
+ // all, so an invalid form never reaches this code; a cancelled
+ // one is handed back in the tick below. (formnovalidate skips
+ // validation and really does submit, which should look like it.)
+ // - Nothing that could change the payload happens while the payload
+ // is being read. The entry list is built before submit fires, but
+ // engines have differed, so aria-busy, the spinner and a
+ // <button>'s TEXT (never submitted — a button submits its value
+ // attribute) go on synchronously, while disabled and an
+ // <input type="submit">'s value (which IS what it submits) wait.
+ //
+ // Every busy element in the DOCUMENT, then the ownership test: a
+ // form="id" submit button is owned by a form it does not live in.
+ function busyOff(form) {
+ form.removeAttribute("aria-busy");
+ document.querySelectorAll('[aria-busy="true"]').forEach(function (b) {
+ if (b.form !== form && !form.contains(b)) return;
+ b.disabled = false;
+ b.removeAttribute("aria-busy");
+ var spin = b.querySelector("[rst-spin]");
+ if (spin) spin.remove();
+ var idle = b.getAttribute("data-idle-label");
+ if (idle === null) return;
+ if (b.tagName === "INPUT") { b.value = idle; } else { b.textContent = idle; }
+ b.removeAttribute("data-idle-label");
+ });
+ }
+
+ function busySubmit(e) {
+ var form = e.target;
+ if (!form || form.tagName !== "FORM") return;
+ if (form.getAttribute("data-busy") === "false") return;
+ // The button the browser submitted with: the one clicked, or — for
+ // Enter in a field — the default one it implicitly clicked. Only
+ // that one goes busy; every other submit button in the form keeps
+ // its name and its value. An engine with no SubmitEvent.submitter
+ // leaves the buttons alone and keeps the guard, which is the half
+ // that matters.
+ var btn = e.submitter;
+ // A form that opens its result elsewhere, or a submit that closes a
+ // dialog and stays put, has nothing to be busy about and nothing to
+ // clear. The submitter's formmethod beats the form's.
+ //
+ // getAttribute, never the IDL properties. A form is
+ // [LegacyOverrideBuiltIns]: a control named "target" or "method" (a
+ // target date, a method column — ordinary field names) shadows the IDL
+ // attribute with the input itself, so the property form quietly
+ // switches the rule off and hands back the double submit. The busy flag
+ // below is the form's own aria-busy for the same reason: an expando is
+ // shadowable, and under strict mode assigning to a shadowed one throws.
+ var to = form.getAttribute("target");
+ if (to && to !== "_self") return;
+ if (/^dialog$/i.test(btn && btn.getAttribute("formmethod") ||
+ form.getAttribute("method"))) return;
+ if (form.getAttribute("aria-busy") === "true") { e.preventDefault(); return; }
+ form.setAttribute("aria-busy", "true");
+ if (!btn || btn.getAttribute("data-busy") === "false") btn = null;
+ var label = btn && (btn.getAttribute("data-busy-label") ||
+ form.getAttribute("data-busy-label"));
+ if (btn) {
+ btn.setAttribute("aria-busy", "true");
+ if (btn.tagName === "BUTTON") {
+ if (label) {
+ btn.setAttribute("data-idle-label", btn.textContent);
+ btn.textContent = label;
+ }
+ // A child element, not a pseudo-element: the shim has to be
+ // able to take it away again. An <input type="submit"> has no
+ // children, which is the one shape this cannot draw a spinner
+ // on. tokens.css stops it rotating under reduced motion.
+ var spin = document.createElement("span");
+ spin.setAttribute("rst-spin", "");
+ spin.setAttribute("aria-hidden", "true");
+ btn.insertBefore(spin, btn.firstChild);
+ }
+ }
+ setTimeout(function () {
+ // Someone downstream cancelled the submit — an app handler doing
+ // the work itself, most likely. Nothing is on its way anywhere,
+ // so hand the form back, guard included, and let whoever took the
+ // job own the feedback too.
+ if (e.defaultPrevented) { busyOff(form); return; }
+ if (!btn) return;
+ if (label && btn.tagName === "INPUT") {
+ btn.setAttribute("data-idle-label", btn.value);
+ btn.value = label;
+ }
+ btn.disabled = true;
+ }, 0);
+ }
+
+ document.addEventListener("submit", busySubmit, true);
+
+ // Light dismiss — the one behaviour the native disclosure genuinely
+ // cannot do, which is the shim's whole admission rule. A <details>
+ // menu closes on a second click of its own summary and on nothing
+ // else: not on a click elsewhere on the page, not on Escape. Both are
+ // what every menu anywhere else does, and neither is expressible in
+ // HTML or CSS.
+ //
+ // Two delegated listeners on the document, never one per element, so a
+ // dropdown that arrives inside a polled fragment is covered the moment
+ // it lands and re-scanning can never double-bind. Capture phase, so a
+ // page whose own handler stops propagation cannot leave a menu stuck
+ // open.
+ //
+ // The scriptless baseline is untouched: with this file removed, the
+ // menus still toggle and the native <details name> group still keeps
+ // one open at a time.
+ //
+ // The nested rst-menu-group is in MENUS even though it is never in the
+ // <details name> group with its parent — the two mechanisms answer
+ // different questions. The name group decides which menus may be open
+ // at once, and a submenu must be exempt from it or opening one would
+ // close the menu around it. Dismissal is not exclusivity: a submenu
+ // left open behind its closing parent is still open the next time the
+ // parent opens, which is a menu remembering a state the user has no
+ // way to see. Listing it here also gives the natural behaviour of
+ // clicking elsewhere INSIDE the parent closing the submenu — the
+ // contains(except) test below already draws that line for free.
+ //
+ // Shell chrome and the toggle-block are deliberately absent from
+ // MENUS. A sidebar's disclosure strip and a settings switch are not
+ // menus; closing them because a click landed elsewhere would fight the
+ // user rather than help.
+ var MENUS = "[rst-dropdown][open],[rst-menu-group][open],[rst-row-menu][open]";
+ MENUS += "," + MENUS.replace(/\[(rst[-\w]+)\]/g, ".$1");
+
+ // except is the clicked node: the menu containing it stays open, which
+ // is what keeps a click on a menu item — or on the summary of a menu
+ // being opened right now — from closing the thing being used.
+ function closeMenus(except) {
+ document.querySelectorAll(MENUS).forEach(function (d) {
+ if (!except || !d.contains(except)) d.open = false;
+ });
+ }
+
+ function dismissMenus(e) {
+ if (e.type === "click") { closeMenus(e.target); return; }
+ if (e.key !== "Escape") return;
+ // Focus is about to be inside a subtree that is no longer rendered,
+ // which strands a keyboard user at the top of the document. Hand it
+ // back to the summary that opened the menu.
+ var el = document.activeElement;
+ var host = el && el.closest ? el.closest(MENUS) : null;
+ // Climb to the OUTERMOST open menu around the focus. closest() finds
+ // the innermost, which for focus inside a submenu is the submenu —
+ // and its summary is inside the parent that is about to close too, so
+ // focusing it would hand focus to something no longer rendered, which
+ // is no hand-back at all.
+ while (host && host.parentElement && host.parentElement.closest(MENUS)) {
+ host = host.parentElement.closest(MENUS);
+ }
+ closeMenus(null);
+ if (host) {
+ var summary = host.querySelector("summary");
+ if (summary) summary.focus();
+ }
+ }
+
+ document.addEventListener("click", dismissMenus, true);
+ document.addEventListener("keydown", dismissMenus, true);
+
+ function scan() {
+ document.querySelectorAll("[data-poll]").forEach(poll);
+ }
+
+ // The back/forward cache restores a page's DOM exactly as it was left
+ // — busy buttons still disabled, still wearing the busy label and the
+ // spinner — so a visitor who navigates back finds a dead form. Hand
+ // every busy form back.
+ window.addEventListener("pageshow", function (e) {
+ if (e.persisted) document.querySelectorAll("form[aria-busy]").forEach(busyOff);
+ });
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", scan);
+ } else {
+ scan();
+ }
+})();
diff --git a/internal/sheets/static/sheets.css b/internal/sheets/static/sheets.css
new file mode 100644
index 0000000..70adf65
--- /dev/null
+++ b/internal/sheets/static/sheets.css
@@ -0,0 +1,128 @@
+/* Sheets' own chrome, on top of rastrillo's tokens.
+ *
+ * Every colour here is a token, never a literal. Two reasons, and the
+ * second is the one that bites: a literal does not follow the person's
+ * theme, and cell fills resolve against the theme, so one hard-coded
+ * grey in the grid chrome is a grey that stays wrong in two themes out
+ * of three. */
+
+/* The grid surface fills what is left of the viewport under the topbar.
+ * It is why this app has its own layout shell rather than rastrillo's:
+ * a spreadsheet is the one screen that should not sit in a padded,
+ * max-width page. */
+[data-sheet-surface] {
+ display: flex;
+ flex-direction: column;
+ height: calc(100dvh - var(--rst-shell-bar-h, 3.5rem));
+ min-height: 0;
+}
+
+[data-sheet-bar] {
+ display: flex;
+ align-items: center;
+ gap: var(--rst-sp-2, 0.5rem);
+ flex-wrap: wrap;
+ padding: var(--rst-sp-2, 0.5rem) var(--rst-sp-3, 0.75rem);
+ border-bottom: 1px solid var(--rst-border);
+ background: var(--rst-surface);
+}
+
+[data-sheet-title] {
+ font-weight: 600;
+ margin-inline-end: auto;
+}
+
+/* The live indicator. It says connected or reconnecting and nothing
+ * else — presence is a later slice, and a dot that claims more than it
+ * knows is worse than no dot. */
+[data-sheet-live] {
+ font-size: var(--rst-fs-sm, 0.875rem);
+ color: var(--rst-text-mut);
+}
+
+[data-sheet-live][data-state="off"] {
+ color: var(--rst-warning, var(--rst-text-mut));
+}
+
+[data-sheet-grid] {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+}
+
+/* Jspreadsheet paints its own colours. These override the ones that
+ * would otherwise ignore the theme entirely — the grid is the surface
+ * where a wrong background is most obvious, because every cell shows
+ * it. */
+[data-sheet-grid] .jexcel,
+[data-sheet-grid] .jexcel > tbody > tr > td,
+[data-sheet-grid] .jexcel > thead > tr > td {
+ background-color: var(--rst-bg);
+ color: var(--rst-text);
+ border-color: var(--rst-border);
+}
+
+[data-sheet-grid] .jexcel > thead > tr > td,
+[data-sheet-grid] .jexcel > tbody > tr > td:first-child {
+ background-color: var(--rst-surface);
+ color: var(--rst-text-mut);
+}
+
+[data-sheet-grid] .jexcel > tbody > tr > td.highlight {
+ background-color: var(--rst-accent-soft, var(--rst-surface));
+}
+
+/* A cell whose imported fill fails WCAG AA against its ink. Spec §11.1
+ * ruled that we retain such a fill rather than silently correcting it,
+ * so the fill stays and this marks it instead: a corner flag that costs
+ * nothing to ignore and is impossible to miss once you look. */
+[data-sheet-grid] td[data-contrast="low"]::after {
+ content: "";
+ position: absolute;
+ inset-block-start: 0;
+ inset-inline-end: 0;
+ border-block-start: 0.5rem solid var(--rst-warning, currentColor);
+ border-inline-start: 0.5rem solid transparent;
+}
+
+[data-sheet-grid] td[data-contrast="low"] {
+ position: relative;
+}
+
+/* The scriptless table. Not a fallback in the apologetic sense: it is
+ * the only view that works everywhere, and every affordance the grid
+ * has must exist here too. */
+[data-cell-table] {
+ border-collapse: collapse;
+ width: 100%;
+}
+
+[data-cell-table] th,
+[data-cell-table] td {
+ border: 1px solid var(--rst-border);
+ padding: var(--rst-sp-1, 0.25rem) var(--rst-sp-2, 0.5rem);
+ text-align: start;
+ vertical-align: top;
+}
+
+[data-cell-table] th {
+ background: var(--rst-surface);
+ color: var(--rst-text-mut);
+ font-weight: 600;
+}
+
+[data-cell-table] td form {
+ display: flex;
+ gap: var(--rst-sp-1, 0.25rem);
+}
+
+[data-cell-table] td input[type="text"] {
+ inline-size: 100%;
+ min-inline-size: 6ch;
+}
+
+[data-cell-value] {
+ display: block;
+ color: var(--rst-text-mut);
+ font-size: var(--rst-fs-sm, 0.875rem);
+}
diff --git a/internal/sheets/static/theme.css b/internal/sheets/static/theme.css
new file mode 100644
index 0000000..c3ab91f
--- /dev/null
+++ b/internal/sheets/static/theme.css
@@ -0,0 +1,174 @@
+/* day — rastrillo's default theme: an everyday blue on white and grey.
+ Structure lives in tokens.css; this file is colour, type and shape.
+ rastrillo new writes it once as static/theme.css.
+
+ The look: the ordinary, personable interface. A white page, a plain
+ grey text scale, one friendly blue that means "this does something",
+ and the conventional green/amber/red for status. Nothing here is
+ trying to be memorable — day is the theme an app ships on while it
+ works out what it wants to look like, and the one that never gets in
+ the way of the content. Semantic status colours are functional and
+ never the accent, so a pill can never be mistaken for a link.
+
+ ── One block, both schemes ───────────────────────────────────────────
+ Every token is declared once, as light-dark(<light>, <dark>), under a
+ single :root that sets color-scheme: light dark. The browser picks the
+ half that matches the resolved scheme, so there is no second copy of
+ the palette to keep in sync and no way to author dark half-done. The
+ two rules at the bottom are the whole toggle: setting color-scheme on
+ :root[data-theme=…] re-resolves every light-dark() in the file at
+ once, in both directions, which is why an explicit choice beats the OS
+ without restating a single colour.
+
+ Tokens with one value in both schemes — the font stack, the radii —
+ are written plain, because light-dark(x, x) says nothing twice.
+
+ light-dark() is a colour function: it may only stand where a <color>
+ is expected. So a shadow token wraps its colour in it and writes the
+ geometry once, outside the call — `0 8px 24px light-dark(a, b)`, never
+ `light-dark(0 8px 24px a, …)`, which no browser would accept as a
+ box-shadow.
+
+ ── The shape axis ────────────────────────────────────────────────────
+ Radius and shadow are the theme's now, not tokens.css's: a theme that
+ can only change colour cannot change how an app feels. day is soft and
+ unremarkable — 8px cards, 6px controls, shadows with a wide blur and
+ little alpha, the depth of paper resting on paper.
+
+ ── WCAG 2.2 AA, measured ─────────────────────────────────────────────
+ Normal text needs 4.5:1; large text and UI component boundaries need
+ 3:1. Every pair below was computed from these exact hex values, in
+ both schemes. If you change a token, recompute its rows.
+
+ LIGHT ratio requirement
+ --rst-text on --rst-surface 16.91 4.5:1 text
+ --rst-text on --rst-bg 16.91 4.5:1 text
+ --rst-text on --rst-surface-2 15.77 4.5:1 text
+ --rst-text on --rst-accent-soft 15.21 4.5:1 text
+ --rst-text-muted on --rst-surface 7.56 4.5:1 text
+ --rst-text-muted on --rst-bg 7.56 4.5:1 text
+ --rst-text-muted on --rst-surface-2 7.05 4.5:1 text
+ --rst-text-muted on --rst-accent-soft 6.80 4.5:1 text
+ --rst-text-faint on --rst-surface 5.29 4.5:1 text
+ --rst-text-faint on --rst-bg 5.29 4.5:1 text
+ --rst-text-faint on --rst-surface-2 4.93 4.5:1 text
+ --rst-text-faint on --rst-accent-soft 4.76 4.5:1 text
+ --rst-accent on --rst-surface 5.28 4.5:1 text + focus ring
+ --rst-accent on --rst-bg 5.28 4.5:1 text + focus ring
+ --rst-accent on --rst-surface-2 4.93 4.5:1 text
+ --rst-accent on --rst-accent-soft 4.75 4.5:1 text
+ --rst-on-accent on --rst-accent 5.28 4.5:1 button label
+ --rst-on-accent on --rst-accent-strong 7.18 4.5:1 button label, hover
+ --rst-line-strong on --rst-surface 3.63 3:1 control border
+ --rst-line-strong on --rst-bg 3.63 3:1 control border
+ --rst-line-strong on --rst-surface-2 3.39 3:1 control border
+ pill neutral fg on its own tint 9.02 4.5:1 text
+ pill positive fg on its own tint 6.49 4.5:1 text
+ pill warning fg on its own tint 6.15 4.5:1 text
+ pill negative fg on its own tint 5.30 4.5:1 text
+ --rst-on-accent on negative fg (fill) 6.47 4.5:1 danger button label
+
+ DARK ratio requirement
+ --rst-text on --rst-surface 13.74 4.5:1 text
+ --rst-text on --rst-bg 15.32 4.5:1 text
+ --rst-text on --rst-surface-2 14.63 4.5:1 text
+ --rst-text on --rst-accent-soft 13.02 4.5:1 text
+ --rst-text-muted on --rst-surface 7.56 4.5:1 text
+ --rst-text-muted on --rst-bg 8.43 4.5:1 text
+ --rst-text-muted on --rst-surface-2 8.05 4.5:1 text
+ --rst-text-muted on --rst-accent-soft 7.16 4.5:1 text
+ --rst-text-faint on --rst-surface 6.33 4.5:1 text
+ --rst-text-faint on --rst-bg 7.06 4.5:1 text
+ --rst-text-faint on --rst-surface-2 6.74 4.5:1 text
+ --rst-text-faint on --rst-accent-soft 6.00 4.5:1 text
+ --rst-accent on --rst-surface 6.95 4.5:1 text + focus ring
+ --rst-accent on --rst-bg 7.75 4.5:1 text + focus ring
+ --rst-accent on --rst-surface-2 7.40 4.5:1 text
+ --rst-accent on --rst-accent-soft 6.59 4.5:1 text
+ --rst-on-accent on --rst-accent 7.86 4.5:1 button label
+ --rst-on-accent on --rst-accent-strong 10.29 4.5:1 button label, hover
+ --rst-line-strong on --rst-surface 4.26 3:1 control border
+ --rst-line-strong on --rst-bg 4.75 3:1 control border
+ --rst-line-strong on --rst-surface-2 4.53 3:1 control border
+ pill neutral fg on its own tint 8.44 4.5:1 text
+ pill positive fg on its own tint 10.18 4.5:1 text
+ pill warning fg on its own tint 9.62 4.5:1 text
+ pill negative fg on its own tint 7.85 4.5:1 text
+ --rst-on-accent on negative fg (fill) 8.99 4.5:1 danger button label
+
+ The tightest pair in the set is --rst-accent on --rst-accent-soft in
+ light, at 4.75:1 against a 4.5:1 floor. Put a token on a surface not
+ listed above and you are guessing; recompute first.
+
+ --rst-line is a hairline divider between rows, not a control border or
+ a meaningful graphic, so 1.4.11's 3:1 does not apply to it. Control
+ borders use --rst-line-strong, which does clear 3:1.
+
+ Pagination chips are the one deliberate exception: they are bordered
+ with --rst-line rather than --rst-line-strong, because a page strip
+ ringed in hard outlines shouts louder than the list it belongs to.
+ 1.4.11 is satisfied without that border — each chip's meaning is
+ carried by its text label at 4.5:1 or better, and the current page
+ adds a filled background, a heavier weight and aria-current on top.
+
+ --rst-shadow-pop, --rst-shadow-knob, --rst-shadow-lift and
+ --rst-overlay are not in the table: they paint a translucent shadow or
+ scrim over whatever sits underneath, not text over a fixed
+ background, so a contrast ratio does not apply. The scrim deepens in
+ dark, matching --rst-shadow-pop's own jump: black reads lighter
+ against a dark backdrop, so the overlay needs more alpha to still
+ read as a dimming layer.
+ ──────────────────────────────────────────────────────────────────── */
+
+:root {
+ color-scheme: light dark;
+
+ /* Type: the family only. Sizes and spacing are structural and stay in
+ tokens.css. */
+ --rst-font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+
+ /* Shape: soft, ordinary, unremarkable. */
+ --rst-radius: 8px;
+ --rst-radius-sm: 6px;
+ --rst-radius-pill: 999px;
+
+ /* Surfaces and lines */
+ --rst-bg: light-dark(#ffffff, #111418);
+ --rst-surface: light-dark(#ffffff, #1a1f26);
+ --rst-surface-2: light-dark(#f6f7f8, #15191f);
+ --rst-line: light-dark(#e5e7eb, #2a3038);
+ --rst-line-strong: light-dark(#7f8792, #79828e);
+
+ /* Text */
+ --rst-text: light-dark(#1a1d21, #e8eaed);
+ --rst-text-muted: light-dark(#4b5563, #a8b0ba);
+ --rst-text-faint: light-dark(#656c78, #98a1ac);
+
+ /* Accent */
+ --rst-accent: light-dark(#2464e0, #7ba7ff);
+ --rst-accent-strong: light-dark(#1a4fc0, #a3c0ff);
+ --rst-accent-soft: light-dark(#eef3fe, #17233a);
+ --rst-on-accent: light-dark(#ffffff, #0b1220);
+
+ /* Status tones */
+ --rst-tone-neutral-fg: light-dark(#374151, #c3c9d1);
+ --rst-tone-neutral-bg: light-dark(#eef0f2, #262c34);
+ --rst-tone-positive-fg: light-dark(#166534, #6ee7a8);
+ --rst-tone-positive-bg: light-dark(#dcfce7, #10281c);
+ --rst-tone-warning-fg: light-dark(#854d0e, #f0c46a);
+ --rst-tone-warning-bg: light-dark(#fef3c7, #2c2110);
+ --rst-tone-negative-fg: light-dark(#b91c1c, #f79aa0);
+ --rst-tone-negative-bg: light-dark(#fee2e2, #331819);
+
+ /* Depth */
+ --rst-shadow-pop: 0 8px 24px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.5));
+ --rst-shadow-knob: 0 1px 2px rgba(0, 0, 0, 0.3);
+ --rst-shadow-lift: 0 1px 2px rgba(0, 0, 0, 0.08);
+ --rst-overlay: light-dark(rgba(0, 0, 0, 0.45), rgba(0, 0, 0, 0.6));
+}
+
+/* The whole toggle. Setting color-scheme re-resolves every light-dark()
+ above, so an explicit choice beats the OS in both directions without
+ restating a colour. */
+:root[data-theme="light"] { color-scheme: light; }
+:root[data-theme="dark"] { color-scheme: dark; }
diff --git a/internal/sheets/static/tokens.css b/internal/sheets/static/tokens.css
new file mode 100644
index 0000000..e855ec7
--- /dev/null
+++ b/internal/sheets/static/tokens.css
@@ -0,0 +1,1550 @@
+/* rastrillo/ui — design tokens and the component classes the shipped
+ partials use (see ui_test.go's TestAllPartialsAreDefined for the
+ current, authoritative list — deliberately not counted here, so this
+ comment cannot go stale as the set grows).
+
+ rastrillo new writes this file once into your app's static/ directory.
+ From then on it is yours: edit it, replace it, delete rules you do not
+ want. Nothing regenerates it, and rastrillo.Serve never serves it —
+ your app's own static handler does.
+
+ Every class, attribute and custom property is prefixed rst- — except
+ the single .icon rule, which styles the class rastrillo.Icon emits —
+ so dropping this file into an app with existing CSS has exactly one
+ known collision surface. There are no imports, no remote assets and no webfont:
+ this stylesheet fetches nothing, from anywhere, ever. That is a test,
+ not a wish (ui_test.go, TestTokensCSSIsSelfContained) — which is also
+ why this comment cannot spell those constructs out literally.
+
+ TWO SPELLINGS, ON PURPOSE, FOR ONE RELEASE. Every component rule
+ below names its class and its attribute twin: `.rst-btn, [rst-btn]`,
+ `.rst-btn--primary, [rst-btn~="primary"]`, `.rst-callout__body,
+ [rst-callout-body]`. The attribute spelling is the vocabulary this
+ library is moving to (design spec §6-v3); the class spelling is what
+ every app and every shipped partial writes today. Pairing them here
+ first is what makes the move non-breaking in both directions — this
+ file is written into your static/ once and frozen there, while the
+ partials upgrade with the module, so an app that has not refreshed
+ its copy must keep styling either way.
+
+ The grammar, so a rule you add follows it: the KIND is a bare
+ attribute; a VARIANT is a token in that attribute's value, matched
+ with ~= so `rst-btn="primary compact"` composes the way a class list
+ does; a PART is its own flat attribute (`rst-callout-body`), never a
+ value — the value slot means variant on every attribute or the
+ grammar is unreadable; and rst-tone carries the four tones that
+ data-tone carries today. Both members of a pair weigh exactly the
+ same: a class and an attribute selector are both (0,1,0), so pairing
+ changes no cascade anywhere. ui_test.go's
+ TestEveryClassSelectorHasAnAttributeTwin holds all of that, including
+ the specificity, and the browser drive proves the two spellings
+ compute identical styles.
+
+ Utilities keep the class spelling and are not paired:
+ rst-sr-only, rst-mono, rst-m-hide, rst-grow, rst-nm, rst-danger,
+ rst-cell-mut. They are not kinds — they are cross-cutting styling,
+ which is what class is for, and they stay class after the move.
+
+ Colour, the type family and shape are not here: they live in
+ themes/day.css (or whichever theme was scaffolded), which rastrillo new
+ writes beside this file as static/theme.css. This file is structure —
+ layout, spacing, the type scale, and the component classes, every one
+ of them painted with var(--rst-*). Swap the theme file and the app
+ changes palette, corner and depth; nothing here is touched.
+
+ Radius and shadow used to live in the scale block below. They moved to
+ the themes in v2, because a theme that can only change colour cannot
+ change how an app feels: day is soft (8px/6px, wide low-alpha
+ shadows), plain is nearly square (4px/3px, one hairline layer) and
+ signal is milled (4px/2px, short dense shadows). tokens.css still
+ paints with var(--rst-radius*) and var(--rst-shadow-*) everywhere; it
+ just no longer decides what they are.
+
+ Padding values are load-bearing for SC 2.5.8 Target Size (AA, new in
+ WCAG 2.2): pagination chips measure about 30px, row action pills
+ about 27px and buttons about 34px on their smaller axis, all clear of
+ the 24px minimum. Do not shrink them without re-measuring.
+
+ Type sizes are rem, not px, so the whole scale tracks a reader who has
+ raised their browser's default font size. Spacing is rem for the same
+ reason. Anything you add here should follow suit.
+ ──────────────────────────────────────────────────────────────────── */
+
+/* ── Scale: type and spacing. Theme-independent. Radius is not here —
+ it is part of the theme's shape axis; see themes/. ────────────── */
+:root {
+ --rst-fs-base: 0.875rem; /* 14px at the browser default */
+ --rst-fs-sm: 0.78125rem; /* 12.5px */
+ --rst-fs-xs: 0.71875rem; /* 11.5px */
+
+ --rst-sp-1: 0.25rem;
+ --rst-sp-2: 0.5rem;
+ --rst-sp-3: 0.75rem;
+ --rst-sp-4: 1rem;
+ --rst-sp-5: 1.5rem;
+ --rst-sp-6: 2.5rem;
+
+ /* 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.
+
+ A token rather than a class for three reasons: the opt-out is one
+ line in your own stylesheet (--rst-scrollbar-gutter: auto), it
+ layers like every other token, and custom properties are untouched
+ by the markup migration, so this survives it without an edit.
+
+ What it costs, chosen knowingly: a page too short to scroll now
+ reserves the strip as well, so there is a thin empty band at the
+ trailing edge where there was none. That is the price of never
+ shifting. both-edges would double the band to keep centred content
+ exactly centred, and was not taken: this system's pages are already
+ a max-width column inside a wider ground, so the asymmetry does not
+ land where it would be seen.
+
+ Do not over-claim what it fixes. macOS overlay scrollbars take no
+ layout space at all, so on a default Mac there was never anything
+ to shift. This is real on Windows and Linux, on a Mac set to
+ "always show scrollbars", and inside the design gallery's own
+ preview iframes. Chrome 94+, Firefox 97+, Safari 18.2+; an older
+ engine ignores the declaration and lands on the behaviour it has
+ today. */
+ --rst-scrollbar-gutter: stable;
+}
+
+/* ── 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. ──────────────────────────────────────────── */
+/* The gutter is declared on the root element, which is where the
+ viewport's scrollbar lives. It also fixes a second instance of the
+ same bug that nobody had attributed to the scrollbar: the modal's
+ scroll lock below (body:has(.rst-backdrop) { overflow: hidden })
+ takes the scrollbar away the instant a modal opens, and without a
+ reserved gutter the whole page slides sideways while the reader
+ watches. One declaration, both cases. */
+html {
+ scrollbar-gutter: var(--rst-scrollbar-gutter);
+}
+
+body {
+ margin: 0;
+ background: var(--rst-bg);
+ color: var(--rst-text);
+ font-family: var(--rst-font);
+ font-size: var(--rst-fs-base);
+ line-height: 1.5;
+}
+
+/* The content column every screen sits in. Your page markup emits this;
+ no partial does. 64rem, widened from 52rem after the v2 page went
+ live: 52rem is a reading measure, and this column holds list grids
+ and side-by-side form fields, which a reading measure squeezes. */
+.rst-page, [rst-page] {
+ box-sizing: border-box;
+ margin: 0 auto;
+ max-width: 64rem;
+ padding: var(--rst-sp-5) var(--rst-sp-4) var(--rst-sp-6);
+}
+
+/* rastrillo.Icon emits class="icon" with no width or height, so the icon
+ sizes from here and tracks the text beside it. */
+.icon {
+ block-size: 1em;
+ flex: none;
+ inline-size: 1em;
+}
+
+/* Focus is visible everywhere inside the app column (.rst-page) and
+ inside any component rendered outside one. :where() contributes no
+ specificity, so any rule below can override it. */
+:where(.rst-page, .rst-page-header, .rst-list, .rst-lbar, .rst-search, .rst-empty, .rst-pagination) :focus-visible, :where([rst-page], [rst-page-header], [rst-list], [rst-lbar], [rst-search], [rst-empty], [rst-pagination]) :focus-visible {
+ outline: 2px solid var(--rst-accent);
+ outline-offset: 2px;
+}
+
+
+/* ── Buttons: page-header's primary action, empty-state's CTA. ────── */
+.rst-btn, [rst-btn] {
+ align-items: center;
+ background: var(--rst-surface);
+ border: 1px solid var(--rst-line-strong);
+ border-radius: var(--rst-radius-sm);
+ color: var(--rst-text);
+ cursor: pointer;
+ display: inline-flex;
+ font-family: inherit;
+ font-size: var(--rst-fs-sm);
+ font-weight: 600;
+ gap: var(--rst-sp-2);
+ line-height: 1.4;
+ padding: 0.45rem 0.75rem;
+ text-decoration: none;
+}
+.rst-btn:hover, [rst-btn]:hover {
+ border-color: var(--rst-accent);
+ color: var(--rst-accent);
+}
+.rst-btn--primary, [rst-btn~="primary"] {
+ background: var(--rst-accent);
+ border-color: var(--rst-accent);
+ color: var(--rst-on-accent);
+}
+.rst-btn--primary:hover, [rst-btn~="primary"]:hover {
+ background: var(--rst-accent-strong);
+ border-color: var(--rst-accent-strong);
+ color: var(--rst-on-accent);
+}
+
+/* ghost — a quiet Cancel beside a primary/danger action: no fill, a
+ muted label, and it darkens toward the ordinary text colour on hover
+ rather than picking up the accent (the accent means "this navigates
+ somewhere new"; a ghost Cancel does not). */
+.rst-btn--ghost, [rst-btn~="ghost"] {
+ background: transparent;
+ color: var(--rst-text-muted);
+}
+.rst-btn--ghost:hover, [rst-btn~="ghost"]:hover {
+ border-color: var(--rst-line-strong);
+ color: var(--rst-text);
+}
+
+/* danger — a solid fill for a destructive submit (confirm-form,
+ bulk-bar's actions menu). --rst-tone-negative-fg already clears
+ 4.5:1 as a filled background against --rst-on-accent's label colour
+ in both themes, so no new custom properties are needed — computed
+ with the same WCAG relative-luminance formula as each theme file's
+ own contrast table (day's values, shown; every theme's own pair is
+ gated the same way, in both schemes):
+ light --rst-on-accent (#ffffff) on --rst-tone-negative-fg (#b91c1c) 6.47:1
+ dark --rst-on-accent (#0b1220) on --rst-tone-negative-fg (#f79aa0) 8.99:1
+ Hover darkens via filter rather than a themed "negative-strong"
+ token: brightness() scales every channel of the already-themed fill
+ (and, being a post-paint filter, the label too), so one rule works
+ in both themes without a new pair of colours to maintain. Re-checked
+ with the label included in the scaled result, both still clear
+ 4.5:1 by a wide margin (day's values again; a post-paint filter
+ result, so these two rows are illustrative rather than gated):
+ light 0.9× fill (#a61919) vs 0.9× label (#e6e6e6) 6.06:1
+ dark 0.9× fill (#de8a90) vs 0.9× label (#0a101c) 7.38:1
+ background/border-color/color are restated on :hover, matching
+ .rst-btn--primary:hover's pattern exactly — .rst-btn:hover (same
+ 0,2,0 specificity, an earlier rule) sets border-color/color to the
+ accent, and a filter-only :hover rule wins nothing for properties it
+ never declares, so without this restatement the label silently
+ cascades to accent-on-red (~1.05:1) at the exact moment a user
+ commits to a destructive action. */
+.rst-btn--danger, [rst-btn~="danger"] {
+ background: var(--rst-tone-negative-fg);
+ border-color: var(--rst-tone-negative-fg);
+ color: var(--rst-on-accent);
+}
+.rst-btn--danger:hover, [rst-btn~="danger"]:hover {
+ background: var(--rst-tone-negative-fg);
+ border-color: var(--rst-tone-negative-fg);
+ color: var(--rst-on-accent);
+ filter: brightness(0.9);
+}
+
+/* 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
+ (.rst-spin, further down) rather than a pseudo-element, because the
+ shim adds and removes it; .rst-btn's own `gap` spaces it from the
+ label, so there is no margin here to keep in step with the padding.
+ :disabled arrives a tick later, once the submission is under way —
+ the two are styled together so the button does not change twice.
+ Nothing here animates, so prefers-reduced-motion has nothing to say
+ to it: .rst-spin already stops rotating under that query. */
+.rst-btn[aria-busy="true"], [rst-btn][aria-busy="true"], .rst-btn:disabled, [rst-btn]:disabled {
+ cursor: default;
+}
+/* Dimmed, but not much. A disabled control is "incidental" under WCAG
+ 1.4.3 and exempt from the contrast minimum, and this one is disabled
+ for a second — so the dimming is a hint that the button is not
+ listening, not a licence to make the label hard to read while
+ someone is waiting on it. */
+.rst-btn:disabled, [rst-btn]:disabled {
+ opacity: 0.8;
+}
+/* The ring must not be squeezed by a long label, and must not stretch
+ with the button's align-items: center default.
+
+ Placed by shape rather than by a name of its own: the busy spinner is
+ the shim's rst-spin child of an rst-btn, and there is nothing else it
+ could be. rst-btn__spin was the second name for one thing, and the
+ grammar of §6-v3 is explicit that a part is styled structurally where
+ structure says it. The shim now writes one attribute. */
+.rst-btn > .rst-spin, [rst-btn] > [rst-spin] {
+ align-self: center;
+ flex: none;
+}
+
+/* ── page-header ──────────────────────────────────────────────────── */
+.rst-page-header, [rst-page-header] {
+ align-items: baseline;
+ border-bottom: 1px solid var(--rst-line);
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--rst-sp-3);
+ justify-content: space-between;
+ margin: 0 0 var(--rst-sp-5);
+ padding-bottom: var(--rst-sp-3);
+ position: relative;
+}
+/* The rake line: one short accent stroke laid over the header's rule.
+ The library's one flourish — the tool it is named for, drawn once per
+ screen and nowhere else. Absolutely positioned, so it is not a flex
+ item. */
+.rst-page-header::after, [rst-page-header]::after {
+ background: var(--rst-accent);
+ block-size: 2px;
+ content: "";
+ inline-size: 2.5rem;
+ inset-block-end: -1px;
+ inset-inline-start: 0;
+ position: absolute;
+}
+.rst-page-header__titles, [rst-page-header-titles] {
+ flex: 1 1 auto;
+ min-inline-size: 0;
+}
+.rst-page-header h1, [rst-page-header] h1 {
+ color: var(--rst-text);
+ font-size: 1.375rem;
+ font-weight: 600;
+ letter-spacing: -0.015em;
+ line-height: 1.25;
+ margin: 0;
+}
+.rst-page-header__sub, [rst-page-header-sub] {
+ color: var(--rst-text-muted);
+ font-size: var(--rst-fs-sm);
+ margin: var(--rst-sp-1) 0 0;
+ max-inline-size: 44rem;
+}
+
+/* ── The list card, its toolbar, and its search field ─────────────── */
+/* No padding here, on purpose: a list card holds rows, and each row pads
+ itself. A form, prose or a strip of links does NOT go in here — it
+ goes in .rst-box, the padded section card. Text touching the border
+ is the sign that content landed in the wrong card. */
+.rst-list, [rst-list] {
+ background: var(--rst-surface);
+ border: 1px solid var(--rst-line);
+ border-radius: var(--rst-radius);
+}
+
+/* Why there is no `overflow: hidden` here, since the obvious way to
+ clip rows to a rounded card is exactly that, and it IS what this rule
+ used to be.
+ A card that clips is a clipping context for everything absolutely
+ positioned inside it — so a bulk bar's Actions menu, or a row kebab's
+ panel, was sliced off at the card's edge the moment it opened. That
+ is any card holding a menu, not one instance of it, so the fix is
+ here rather than at the bulk bar.
+ `overflow: clip` is the same trap: it clips too. `overflow-clip-margin`
+ would push the clip box outward, but it pushes it outward on every
+ side and past the border radius — which is the corner clipping this
+ whole rule exists to do. So the card stops clipping and the rows round
+ THEMSELVES: the first full-bleed child takes the card's start corners,
+ the last takes its end corners, one step in for the 1px border. A
+ card's own rows (.rst-row, .rst-lrow, .rst-lbar, .rst-bulkbar) declare
+ no radius, which is what makes them full-bleed in the first place.
+
+ Both selectors are wrapped in :where() to weigh NOTHING — (0,0,0),
+ not the (0,2,0) a bare `.rst-list > :first-child` would carry. That is
+ the whole mechanism by which a child that owns its own shape keeps it:
+ a bare <form rst-search> or a .rst-empty as the direct first
+ or last child of a card declares its radius at (0,1,0) and therefore
+ wins, and so does any app class doing the same. Source order does NOT
+ decide this and cannot be relied on to — an earlier draft of this
+ comment said it did, and both those children painted lopsided corners
+ (7px over 6px in day) because (0,2,0) beat (0,1,0) whatever the order.
+ :where() rather than a :not(.rst-search, .rst-empty) exception list
+ because the list would need extending for every self-shaped child an
+ app or a later partial invents, and the thing being expressed is not
+ "except these two" but "unless the child says otherwise".
+ TestSelfShapedChildrenKeepTheirCornersInsideACard gates the
+ relationship, by measuring the same child inside a card and outside
+ one and requiring the two to agree.
+
+ KNOWN EDGE, not handled: :last-child is a DOM position, and the old
+ `overflow: hidden` clipped whatever was PAINTED last. So a trailing
+ <input type="hidden"> — which apps write constantly, and which has
+ display: none from the UA — takes the rounding and leaves the real
+ last row square. No template in the framework does this, so nothing
+ regressed; it is unhandled rather than overlooked because the CSS that
+ would handle it has to enumerate what "invisible" means in a selector
+ (:nth-last-child(1 of :not([type=hidden]):not([hidden]))), and the
+ commonest case of all — display: none from the app's own stylesheet —
+ is not expressible in a selector at all. A rule that covered two of
+ the three would read as covering all three. If you are looking at a
+ square corner on a rounded card, look for a hidden last child. */
+:where(.rst-list) > :where(:first-child),
+:where([rst-list]) > :where(:first-child),
+:where(.rst-card) > :where(:first-child),
+:where([rst-card]) > :where(:first-child) {
+ border-start-end-radius: calc(var(--rst-radius) - 1px);
+ border-start-start-radius: calc(var(--rst-radius) - 1px);
+}
+:where(.rst-list) > :where(:last-child),
+:where([rst-list]) > :where(:last-child),
+:where(.rst-card) > :where(:last-child),
+:where([rst-card]) > :where(:last-child) {
+ border-end-end-radius: calc(var(--rst-radius) - 1px);
+ border-end-start-radius: calc(var(--rst-radius) - 1px);
+}
+
+/* list-bar: the toolbar strip. The search field is capped at 20rem so
+ the space to its right stays reserved — that is where the filter and
+ sort controls attach when the dropdown slice lands. */
+.rst-lbar, [rst-lbar] {
+ align-items: center;
+ background: var(--rst-surface-2);
+ border-bottom: 1px solid var(--rst-line);
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--rst-sp-3);
+ padding: var(--rst-sp-2) var(--rst-sp-3);
+}
+/* list-bar-search wraps its form in <search>, the landmark element, so
+ the strip's flex child is the <search>, not the form. Both are sized
+ here: an app that hand-writes a bare <form rst-search> into
+ its own .rst-lbar keeps the layout it had before the element landed. */
+.rst-lbar > search,
+[rst-lbar] > search,
+.rst-lbar > .rst-search,
+[rst-lbar] > [rst-search] {
+ flex: 0 1 20rem;
+}
+
+/* list-bar-search renders this form, which is the field box itself. Used
+ on its own inside a .rst-list card it just needs margin; inside a
+ .rst-lbar the strip supplies the padding. The <search> around it is a
+ plain block box — it draws nothing, so the form remains the field. */
+.rst-search, [rst-search] {
+ align-items: center;
+ background: var(--rst-surface);
+ border: 1px solid var(--rst-line-strong);
+ border-radius: var(--rst-radius-sm);
+ display: flex;
+ gap: var(--rst-sp-2);
+ margin: 0;
+ padding: 0.3rem 0.55rem;
+}
+.rst-list > search,
+[rst-list] > search,
+.rst-list > .rst-search,
+[rst-list] > [rst-search] {
+ margin: var(--rst-sp-3);
+}
+.rst-search .icon, [rst-search] .icon {
+ color: var(--rst-text-faint);
+}
+.rst-search input[type="search"], [rst-search] input[type="search"] {
+ background: transparent;
+ border: 0;
+ color: var(--rst-text);
+ flex: 1;
+ font-family: inherit;
+ font-size: var(--rst-fs-sm);
+ min-inline-size: 0;
+ padding: 0.2rem 0;
+}
+.rst-search input[type="search"]::placeholder, [rst-search] input[type="search"]::placeholder {
+ color: var(--rst-text-faint);
+}
+/* The field box carries the focus indicator for the whole control, so
+ the input's own default outline would only double it up. */
+.rst-search input[type="search"]:focus-visible, [rst-search] input[type="search"]:focus-visible {
+ outline: none;
+}
+.rst-search:focus-within, [rst-search]:focus-within {
+ border-color: var(--rst-accent);
+ outline: 2px solid var(--rst-accent);
+ outline-offset: 1px;
+}
+/* The browser's own ✕ goes (design spec §6-v2.1b.6). It is
+ ::-webkit-search-cancel-button and it does exactly what it is
+ specified to do — it clears the input's VALUE — but a GET form
+ submits on submit, so the results stand and the address bar still
+ says ?q=. list-bar-search renders a real link in its place, and
+ leaving both would be two affordances, one of which lies.
+ -webkit-appearance is not redundant beside display: none: without it
+ WebKit reserves the button's box before the display ever applies. */
+.rst-search input[type="search"]::-webkit-search-cancel-button, [rst-search] input[type="search"]::-webkit-search-cancel-button {
+ -webkit-appearance: none;
+ appearance: none;
+ display: none;
+}
+/* The link that replaces it. 24px on BOTH axes is WCAG 2.2 SC 2.5.8
+ Target Size (AA), which this library gates in CI — an ✕ tucked inside
+ a field is exactly the shape that fails it, and a 17px chip on this
+ branch already did. Do not shrink it without re-measuring. */
+.rst-search__clear, [rst-search-clear] {
+ align-items: center;
+ border-radius: var(--rst-radius-sm);
+ color: var(--rst-text-faint);
+ display: inline-flex;
+ flex: none;
+ justify-content: center;
+ min-block-size: 24px;
+ min-inline-size: 24px;
+ text-decoration: none;
+}
+.rst-search__clear:hover, [rst-search-clear]:hover {
+ background: var(--rst-accent-soft);
+ color: var(--rst-text);
+}
+
+/* ── list-row-action ──────────────────────────────────────────────── */
+.rst-row, [rst-row] {
+ align-items: center;
+ border-top: 1px solid var(--rst-line);
+ display: flex;
+ gap: var(--rst-sp-3);
+ padding: var(--rst-sp-3);
+ position: relative;
+}
+.rst-row:first-child, [rst-row]:first-child {
+ border-top: 0;
+}
+/* The toolbar strip already draws the line under itself. */
+.rst-lbar + .rst-row, [rst-lbar] + [rst-row] {
+ border-top: 0;
+}
+.rst-row:hover, [rst-row]:hover {
+ background: var(--rst-surface-2);
+}
+.rst-row__lead, [rst-row-lead] {
+ align-items: center;
+ background: var(--rst-tone-neutral-bg);
+ block-size: 1.9rem;
+ border-radius: 50%;
+ color: var(--rst-tone-neutral-fg);
+ display: flex;
+ flex: none;
+ font-size: var(--rst-fs-xs);
+ font-weight: 600;
+ inline-size: 1.9rem;
+ justify-content: center;
+}
+.rst-row__lead[data-lead="positive"], [rst-row-lead][data-lead="positive"] {
+ background: var(--rst-tone-positive-bg);
+ color: var(--rst-tone-positive-fg);
+}
+.rst-row__lead[data-lead="warning"], [rst-row-lead][data-lead="warning"] {
+ background: var(--rst-tone-warning-bg);
+ color: var(--rst-tone-warning-fg);
+}
+.rst-row__lead[data-lead="negative"], [rst-row-lead][data-lead="negative"] {
+ background: var(--rst-tone-negative-bg);
+ color: var(--rst-tone-negative-fg);
+}
+.rst-row__lead[data-lead="accent"], [rst-row-lead][data-lead="accent"] {
+ background: var(--rst-accent-soft);
+ color: var(--rst-accent);
+}
+.rst-row__main, [rst-row-main] {
+ 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. */
+.rst-row__main > a, [rst-row-main] > a {
+ color: var(--rst-text);
+ font-weight: 600;
+ text-decoration: none;
+}
+.rst-row__main > a::after, [rst-row-main] > a::after {
+ content: "";
+ inset: 0;
+ position: absolute;
+}
+.rst-row__main > a:hover, [rst-row-main] > a:hover {
+ text-decoration: underline;
+}
+.rst-row__sub, [rst-row-sub] {
+ color: var(--rst-text-muted);
+ display: block;
+ font-size: var(--rst-fs-sm);
+ margin-block-start: 0.1rem;
+}
+.rst-row__action, [rst-row-action] {
+ background: var(--rst-surface);
+ border: 1px solid var(--rst-line-strong);
+ border-radius: var(--rst-radius-pill);
+ color: var(--rst-text-muted);
+ flex: none;
+ font-size: var(--rst-fs-sm);
+ padding: 0.2rem 0.7rem;
+ position: relative;
+ text-decoration: none;
+ z-index: 1;
+}
+.rst-row__action:hover, [rst-row-action]:hover {
+ border-color: var(--rst-accent);
+ color: var(--rst-accent);
+}
+
+/* A status pill inside a row sits above the name link's overlay, like
+ the action pill does. */
+.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. ──── */
+.rst-status, [rst-status] {
+ align-items: center;
+ background: var(--rst-tone-neutral-bg);
+ border-radius: var(--rst-radius-pill);
+ color: var(--rst-tone-neutral-fg);
+ display: inline-flex;
+ font-size: var(--rst-fs-xs);
+ font-weight: 600;
+ gap: 0.35rem;
+ line-height: 1.6;
+ padding: 0.15rem 0.55rem;
+}
+.rst-status::before, [rst-status]::before {
+ background: currentColor;
+ block-size: 0.375rem;
+ border-radius: 50%;
+ content: "";
+ flex: none;
+ inline-size: 0.375rem;
+}
+.rst-status[data-tone="positive"], [rst-status][rst-tone~="positive"] {
+ background: var(--rst-tone-positive-bg);
+ color: var(--rst-tone-positive-fg);
+}
+.rst-status[data-tone="warning"], [rst-status][rst-tone~="warning"] {
+ background: var(--rst-tone-warning-bg);
+ color: var(--rst-tone-warning-fg);
+}
+.rst-status[data-tone="negative"], [rst-status][rst-tone~="negative"] {
+ background: var(--rst-tone-negative-bg);
+ color: var(--rst-tone-negative-fg);
+}
+
+/* ── empty-state ──────────────────────────────────────────────────── */
+.rst-empty, [rst-empty] {
+ background: var(--rst-surface);
+ border: 1px dashed var(--rst-line-strong);
+ border-radius: var(--rst-radius);
+ padding: var(--rst-sp-6) var(--rst-sp-4);
+ text-align: center;
+}
+/* Class-based, so it styles the partial's <h2> without inheriting any
+ <h2> default: an empty state's heading is a real heading in the
+ document outline, not a paragraph dressed up as one. */
+.rst-empty__title, [rst-empty-title] {
+ color: var(--rst-text);
+ font-size: 1.0625rem;
+ font-weight: 600;
+ margin: 0 0 var(--rst-sp-2);
+}
+.rst-empty__body, [rst-empty-body] {
+ color: var(--rst-text-muted);
+ margin: 0 auto;
+ max-inline-size: 34rem;
+}
+.rst-empty__cta, [rst-empty-cta] {
+ margin: var(--rst-sp-4) 0 0;
+}
+
+/* ── pagination. The current page is a filled chip AND aria-current AND
+ bolder — three signals, so it never rests on colour alone. ────── */
+.rst-pagination, [rst-pagination] {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ font-variant-numeric: tabular-nums;
+ gap: var(--rst-sp-1);
+ margin: var(--rst-sp-4) 0;
+}
+.rst-pagination a,
+[rst-pagination] a,
+.rst-pagination span,
+[rst-pagination] span {
+ border: 1px solid var(--rst-line);
+ border-radius: var(--rst-radius-sm);
+ color: var(--rst-text-muted);
+ display: inline-block;
+ font-size: var(--rst-fs-sm);
+ min-inline-size: 2rem;
+ padding: 0.3rem 0.6rem;
+ text-align: center;
+ text-decoration: none;
+}
+.rst-pagination a:hover, [rst-pagination] a:hover {
+ background: var(--rst-accent-soft);
+ border-color: var(--rst-accent);
+ color: var(--rst-text);
+}
+.rst-pagination [aria-current="page"], [rst-pagination] [aria-current="page"] {
+ background: var(--rst-accent);
+ border-color: var(--rst-accent);
+ color: var(--rst-on-accent);
+ font-weight: 600;
+}
+.rst-pagination .rst-pagination__disabled, [rst-pagination] [rst-pagination-disabled] {
+ border-style: dashed;
+ color: var(--rst-text-faint);
+}
+.rst-pagination .rst-pagination__gap, [rst-pagination] [rst-pagination-gap] {
+ border-color: transparent;
+ min-inline-size: 0;
+ padding-inline: var(--rst-sp-1);
+}
+
+/* badge — uppercase bordered chip; tones reuse the pill tone pairs. */
+.rst-badge, [rst-badge] {
+ border: 1px solid var(--rst-line);
+ border-radius: var(--rst-radius-sm);
+ color: var(--rst-text-muted);
+ display: inline-block;
+ font-size: 0.6875rem;
+ font-variant-numeric: tabular-nums;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ padding: 0.05rem 0.4rem;
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+.rst-badge--positive, [rst-badge~="positive"] { background: var(--rst-tone-positive-bg); border-color: var(--rst-tone-positive-fg); color: var(--rst-tone-positive-fg); }
+.rst-badge--warning, [rst-badge~="warning"] { background: var(--rst-tone-warning-bg); border-color: var(--rst-tone-warning-fg); color: var(--rst-tone-warning-fg); }
+.rst-badge--negative, [rst-badge~="negative"] { background: var(--rst-tone-negative-bg); border-color: var(--rst-tone-negative-fg); color: var(--rst-tone-negative-fg); }
+.rst-badge--neutral, [rst-badge~="neutral"] { background: var(--rst-tone-neutral-bg); border-color: var(--rst-tone-neutral-fg); color: var(--rst-tone-neutral-fg); }
+
+/* meter — 4px capacity bar; the number rides beside it as text. */
+.rst-meter, [rst-meter] { align-items: center; color: var(--rst-text-muted); display: inline-flex; font-size: var(--rst-fs-xs); gap: var(--rst-sp-2); }
+.rst-meter__bar, [rst-meter-bar] { background: var(--rst-accent-soft); border-radius: 2px; flex: 1; height: 4px; min-width: 34px; overflow: hidden; }
+.rst-meter__bar i, [rst-meter-bar] i { background: var(--rst-accent); border-radius: 2px; display: block; height: 100%; width: var(--rst-meter-fill, 0%); }
+.rst-meter__num, [rst-meter-num] { font-variant-numeric: tabular-nums; white-space: nowrap; }
+
+/* person — avatar + name + email; avatar is decoration, text is identity. */
+.rst-person, [rst-person] { align-items: center; color: inherit; display: inline-flex; gap: 0.7rem; min-width: 0; text-decoration: none; }
+.rst-person__av, [rst-person-av] { align-items: center; background: var(--rst-accent); border-radius: 50%; color: var(--rst-on-accent); display: flex; flex: none; font-size: 0.6875rem; font-weight: 600; height: 28px; justify-content: center; width: 28px; }
+.rst-person__av--empty, [rst-person-av~="empty"] { background: transparent; border: 1px dashed var(--rst-line-strong); color: transparent; }
+.rst-person__meta, [rst-person-meta] { min-width: 0; }
+.rst-person__name, [rst-person-name] { display: block; font-weight: 550; line-height: 1.25; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.rst-person__email, [rst-person-email] { color: var(--rst-text-muted); display: block; font-size: var(--rst-fs-xs); line-height: 1.25; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.rst-person:hover .rst-person__name, [rst-person]:hover [rst-person-name] { color: var(--rst-accent); }
+.rst-person--lg .rst-person__av, [rst-person~="lg"] [rst-person-av] { font-size: 1.0625rem; height: 46px; width: 46px; }
+
+/* callout — the one alert vocabulary; icon sized to the first text line. */
+.rst-callout, [rst-callout] { align-items: flex-start; background: var(--rst-surface-2); border: 1px solid var(--rst-line-strong); border-radius: var(--rst-radius); display: flex; gap: 0.6rem; margin: var(--rst-sp-4) 0; padding: 0.85rem 1rem; }
+.rst-callout__ic, [rst-callout-ic] { align-items: center; color: var(--rst-accent); display: inline-flex; flex: none; height: 1.5rem; justify-content: center; width: 1.25rem; }
+.rst-callout__ic svg, [rst-callout-ic] svg, .rst-callout__ic .icon, [rst-callout-ic] .icon { display: block; height: 1.25rem; width: 1.25rem; }
+.rst-callout__body, [rst-callout-body] { line-height: 1.5; min-width: 0; }
+.rst-callout__body > strong, [rst-callout-body] > strong { display: block; font-weight: 600; }
+.rst-callout__body > p, [rst-callout-body] > p { color: var(--rst-text-muted); margin: 0.15rem 0 0; }
+.rst-callout__body > p:only-child, [rst-callout-body] > p:only-child { color: inherit; margin: 0; }
+.rst-callout__body > ul, [rst-callout-body] > ul { color: var(--rst-text-muted); margin: 0.15rem 0 0; padding-inline-start: 1.1rem; }
+.rst-callout[data-tone="positive"], [rst-callout][rst-tone~="positive"] { background: var(--rst-tone-positive-bg); border-color: var(--rst-tone-positive-fg); }
+.rst-callout[data-tone="positive"] > .rst-callout__ic, [rst-callout][rst-tone~="positive"] > [rst-callout-ic] { color: var(--rst-tone-positive-fg); }
+.rst-callout[data-tone="warning"], [rst-callout][rst-tone~="warning"] { background: var(--rst-tone-warning-bg); border-color: var(--rst-tone-warning-fg); }
+.rst-callout[data-tone="warning"] > .rst-callout__ic, [rst-callout][rst-tone~="warning"] > [rst-callout-ic] { color: var(--rst-tone-warning-fg); }
+.rst-callout[data-tone="negative"], [rst-callout][rst-tone~="negative"] { background: var(--rst-tone-negative-bg); border-color: var(--rst-tone-negative-fg); }
+.rst-callout[data-tone="negative"] > .rst-callout__ic, [rst-callout][rst-tone~="negative"] > [rst-callout-ic] { color: var(--rst-tone-negative-fg); }
+
+/* detail-list — a record's labelled facts as a definition list; the
+ partial emits .rst-detail, .rst-mono marking machine-ish values. */
+.rst-detail, [rst-detail] { display: grid; gap: var(--rst-sp-2) var(--rst-sp-5); grid-template-columns: max-content 1fr; margin: var(--rst-sp-4) 0; }
+.rst-detail dt, [rst-detail] dt { color: var(--rst-text-muted); font-size: var(--rst-fs-sm); font-weight: 600; }
+.rst-detail dd, [rst-detail] dd { margin: 0; }
+.rst-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: var(--rst-fs-sm); }
+
+/* ── Narrow screens ───────────────────────────────────────────────── */
+@media (max-width: 34rem) {
+ .rst-lbar > search,
+ [rst-lbar] > search,
+ .rst-lbar > .rst-search,
+ [rst-lbar] > [rst-search] {
+ flex: 1 1 100%;
+ }
+ .rst-row, [rst-row] {
+ align-items: flex-start;
+ flex-wrap: wrap;
+ }
+ .rst-page-header h1, [rst-page-header] h1 {
+ font-size: 1.25rem;
+ }
+}
+
+/* ── The structural idioms: box, list grid, row-menu, dropdown, ftok ── */
+
+/* box — the section card. Its heading (rst-box-head) is a SIBLING
+ before the box, never inside it: a screen is a stack of
+ section-header + card. A box-head action is a compact real button,
+ never a pill. */
+.rst-box, [rst-box] { background: var(--rst-surface); border: 1px solid var(--rst-line); border-radius: var(--rst-radius); margin: var(--rst-sp-4) 0; padding: 1.1rem 1.25rem; }
+.rst-box > :first-child, [rst-box] > :first-child { margin-top: 0; }
+.rst-box > :last-child, [rst-box] > :last-child { margin-bottom: 0; }
+.rst-box-head, [rst-box-head] { align-items: baseline; display: flex; gap: var(--rst-sp-4); justify-content: space-between; margin: 1.5rem 0 0.5rem; }
+.rst-box-head:first-child, [rst-box-head]:first-child { margin-top: 0; }
+.rst-box-head + .rst-box, [rst-box-head] + [rst-box] { margin-top: 0; }
+.rst-box-head h2, [rst-box-head] h2 { font-size: var(--rst-fs-base); margin: 0; }
+.rst-box-foot, [rst-box-foot] { border-top: 1px solid var(--rst-line); color: var(--rst-text-muted); margin-top: var(--rst-sp-4); padding-top: 0.75rem; }
+.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.
+ 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-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; }
+.rst-cell-mut { color: var(--rst-text-muted); font-size: var(--rst-fs-xs); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.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; }
+@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; }
+}
+
+/* row-menu — the per-row kebab: native details/summary, no JS. The
+ destructive item sits last, class rst-danger, label ending "…". */
+.rst-row-menu, [rst-row-menu] { justify-self: end; position: relative; }
+.rst-row-menu > summary, [rst-row-menu] > summary { align-items: center; border-radius: var(--rst-radius-sm); color: var(--rst-text-faint); cursor: pointer; display: flex; height: 26px; justify-content: center; list-style: none; width: 26px; }
+.rst-row-menu > summary::-webkit-details-marker, [rst-row-menu] > summary::-webkit-details-marker { display: none; }
+.rst-row-menu > summary:hover, [rst-row-menu] > summary:hover, .rst-row-menu[open] > summary, [rst-row-menu][open] > summary { background: var(--rst-accent-soft); color: var(--rst-text); }
+.rst-row-menu__panel, [rst-row-menu-panel] { background: var(--rst-surface); border: 1px solid var(--rst-line); border-radius: 9px; box-shadow: var(--rst-shadow-pop); inset-inline-end: 0; min-width: 176px; padding: 0.25rem; position: absolute; top: calc(100% + 4px); z-index: 40; }
+.rst-row-menu__panel a, [rst-row-menu-panel] a, .rst-row-menu__panel button, [rst-row-menu-panel] button { background: none; border: 0; border-radius: var(--rst-radius-sm); color: var(--rst-text); cursor: pointer; display: block; font: inherit; font-size: var(--rst-fs-sm); font-weight: 450; margin: 0; padding: 0.4rem 0.65rem; text-align: start; text-decoration: none; width: 100%; }
+.rst-row-menu__panel a:hover, [rst-row-menu-panel] a:hover, .rst-row-menu__panel button:hover, [rst-row-menu-panel] button:hover { background: var(--rst-accent-soft); }
+/* The separator every menu surface draws. Scoped to the row menu alone
+ until the live page showed the topbar account menu wearing the UA's
+ thick inset 3D <hr> instead: that panel is a .rst-dropdown__menu, and
+ nothing named it. The three panels are one surface with three entry
+ points, so one rule names all three — .rst-locale redundantly, since
+ its panel carries .rst-dropdown__menu too, but a locale menu built
+ without that class is still a menu and still needs the hairline. */
+.rst-row-menu__panel hr,
+[rst-row-menu-panel] hr,
+.rst-dropdown__menu hr,
+[rst-dropdown-menu] hr,
+.rst-locale hr,
+[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); }
+
+/* dropdown — the details/summary menu vocabulary (header overflow,
+ list-bar Filter/Sort). Exclusivity between siblings is the native
+ details name attribute — zero JS. */
+.rst-dropdown, [rst-dropdown] { position: relative; }
+.rst-dropdown > summary, [rst-dropdown] > summary { align-items: center; color: var(--rst-text-muted); cursor: pointer; display: flex; gap: 0.3rem; list-style: none; padding: 0.4rem 0.6rem; white-space: nowrap; }
+.rst-dropdown > summary::-webkit-details-marker, [rst-dropdown] > summary::-webkit-details-marker { display: none; }
+.rst-dropdown > summary:hover, [rst-dropdown] > summary:hover, .rst-dropdown[open] > summary, [rst-dropdown][open] > summary { color: var(--rst-text); }
+.rst-dropdown__menu, [rst-dropdown-menu] { background: var(--rst-surface); border: 1px solid var(--rst-line); border-radius: 9px; box-shadow: var(--rst-shadow-pop); inset-inline-end: 0; margin-top: 4px; min-width: 176px; padding: 0.25rem; position: absolute; top: 100%; z-index: 30; }
+.rst-dropdown__menu a, [rst-dropdown-menu] a { border-radius: var(--rst-radius-sm); color: inherit; display: block; font-size: var(--rst-fs-sm); padding: 0.4rem 0.65rem; text-decoration: none; }
+.rst-dropdown__menu a:hover, [rst-dropdown-menu] a:hover, .rst-dropdown__menu a[aria-current], [rst-dropdown-menu] a[aria-current] { background: var(--rst-accent-soft); }
+/* locale-menu — the language switcher is a dropdown whose items are
+ one-field POST forms (spec §2.4), so the button inside each form
+ takes the menu-link styling. Autonyms keep their own script, so no
+ text-transform here, ever. */
+.rst-locale form, [rst-locale] form { margin: 0; }
+.rst-locale button, [rst-locale] button { background: none; border: 0; border-radius: var(--rst-radius-sm); color: var(--rst-text); cursor: pointer; display: block; font: inherit; font-size: var(--rst-fs-sm); padding: 0.4rem 0.65rem; text-align: start; width: 100%; }
+.rst-locale button:hover, [rst-locale] button:hover { background: var(--rst-accent-soft); }
+.rst-locale button[aria-current], [rst-locale] button[aria-current] { color: var(--rst-accent); font-weight: 600; }
+/* Task 2's menu only ever held links; bulk-bar's actions menu (Task 4)
+ is real submit buttons on the surrounding form, so the same panel
+ needs button styling too — mirrors .rst-row-menu__panel's combined
+ a/button rule above. */
+.rst-dropdown__menu button, [rst-dropdown-menu] button { background: none; border: 0; border-radius: var(--rst-radius-sm); color: inherit; cursor: pointer; display: block; font: inherit; font-size: var(--rst-fs-sm); margin: 0; padding: 0.4rem 0.65rem; text-align: start; width: 100%; }
+.rst-dropdown__menu button:hover, [rst-dropdown-menu] button:hover { background: var(--rst-accent-soft); }
+/* The same content-box bug the inputs had, in the three menu panels:
+ a full-width button inside a 0.25rem-padded panel measured 100% of
+ the content box and then added its own 0.65rem of side padding, so
+ every hover highlight bled ~20.8px past the panel's rounded edge.
+ One rule for all three panels, because it is one cause. */
+.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, .rst-dropdown__menu button, [rst-dropdown-menu] button { box-sizing: border-box; }
+.rst-dropdown__menu .rst-danger:hover, [rst-dropdown-menu] .rst-danger:hover { background: var(--rst-tone-negative-bg); }
+.rst-dropdown__menu .rst-menu-group > summary, [rst-dropdown-menu] [rst-menu-group] > summary { color: var(--rst-text-muted); cursor: pointer; font-size: var(--rst-fs-sm); list-style: none; padding: 0.4rem 0.65rem; }
+.rst-dropdown__menu .rst-menu-group > summary::-webkit-details-marker, [rst-dropdown-menu] [rst-menu-group] > summary::-webkit-details-marker { display: none; }
+.rst-dropdown__menu .rst-menu-group > div, [rst-dropdown-menu] [rst-menu-group] > div { padding-inline-start: 0.6rem; }
+/* Menus that fit the viewport (design spec §6-v2.1b, 1 and 2). Two
+ bugs, one surface — and one rule for all four panels, because
+ .rst-combo__list and .rst-dtp__list have carried the cap and the
+ scroll since they were written and these two never did.
+
+ THE CAP AND THE SCROLL come first because they work in every engine.
+ The twelve-locale language menu measures 388px; on a short viewport
+ its last entries were simply unreachable — no cap, no scroll, nothing
+ to drag. The cap is the space a menu may take: 20rem where the window
+ is tall, and the window itself less the room the anchor and the
+ page's own chrome need where it is not. overscroll-behavior keeps a
+ flick that bottoms the menu out from carrying on into the page behind
+ it.
+
+ THE 8rem FLOOR IS NOT DECORATION, and it went in after the first
+ version of this rule shipped without one. dvh is the viewport, and
+ inside an iframe the viewport is THE FRAME — so in the design
+ gallery's own 100px preview frames `100dvh - 6rem` computed to 4px
+ and a three-item menu rendered as a 12px sliver; below a 96px frame
+ it computed to zero and the menu was invisible. A fix aimed at menu
+ entries nobody could reach had shipped a menu with no entries at all.
+ The floor is the honest trade: in a frame too short for the menu it
+ overflows, and an overflowing menu can be scrolled to, where an
+ invisible one cannot be anything. Any app embedding a screen in a
+ short iframe had the same bug, so this is not a gallery fix.
+
+ THE FLIP is CSS anchor positioning. A menu with no room below opens
+ upward; one against the trailing edge opens the other way inline.
+ Zero script.
+
+ It is Chromium-only today. That is accepted, and the reason is
+ recorded here rather than left looking like an oversight: an engine
+ without it lands on the fixed position above, which is exactly what
+ every engine does today, so nothing regresses — and Firefox and
+ Safari gain the behaviour with no release from us. The alternative
+ was script, which would put positioning behind JavaScript and make
+ the scriptless path worse than the one we already ship. That trade is
+ the wrong way round for this library.
+
+ anchor-scope is load-bearing, not decoration. anchor-name is a
+ document-wide name, so without a scope every menu on the page would
+ resolve --rst-menu to the LAST element carrying it and all of them
+ would pile onto one anchor. Scoping the name to each disclosure's own
+ subtree is what lets one name serve every menu in the library.
+
+ Inside the @supports the fixed insets have to go. position-area
+ supplies the containing block, so a leftover top: 100% would then be
+ measured against THAT box and push the panel a whole anchor-height
+ further down again. The margin stays: position-try-fallbacks flips
+ margins along with insets, so one margin-block-start is the 4px gap
+ in both directions.
+
+ The cap is deliberately NOT a percentage of the position-area. A
+ percentage would be the exact space available, which sounds better
+ and is worse: an element clamped to fit never overflows, and overflow
+ is precisely what triggers position-try-fallbacks, so the menu would
+ scroll in place at the foot of the window instead of flipping up into
+ the room above it.
+
+ position-visibility covers the fixed positioning above. A fixed panel
+ is deliberately outside every scrolling ancestor's clip — that is how
+ a menu opened inside a card escapes it — but the anchor is NOT, so a
+ rail's nav scrolled until the menu's own button is gone could leave
+ the menu painted over unrelated content with nothing under it.
+ anchors-visible ties the panel's visibility to its anchor's: when the
+ button goes, the menu goes.
+
+ It is a no-op in Chromium today, and that is written down rather than
+ left to be rediscovered: Chromium's INITIAL value for the property is
+ already anchors-visible, on every element. The CSS spec's initial
+ value is `always`. So this declaration is not what produces today's
+ behaviour — it is what keeps it if an engine ever aligns with the
+ spec, which would otherwise orphan every menu here with no warning at
+ all. ui's TestAMenuDoesNotOutliveTheAnchorScrolledAwayFromUnderIt
+ watches for exactly that, and says the same at more length. */
+.rst-dropdown__menu,
+[rst-dropdown-menu],
+.rst-row-menu__panel,
+[rst-row-menu-panel] {
+ max-block-size: min(20rem, max(calc(100dvh - 6rem), 8rem));
+ overflow-y: auto;
+ overscroll-behavior: contain;
+}
+@supports (position-area: block-end) and (position-try-fallbacks: flip-block) {
+ .rst-dropdown,
+ [rst-dropdown],
+ .rst-row-menu,
+ [rst-row-menu] {
+ anchor-name: --rst-menu;
+ anchor-scope: --rst-menu;
+ }
+ .rst-dropdown__menu,
+ [rst-dropdown-menu],
+ .rst-row-menu__panel,
+ [rst-row-menu-panel] {
+ inset: auto;
+ margin-block-start: 4px;
+ position: fixed;
+ position-anchor: --rst-menu;
+ position-area: block-end span-inline-start;
+ position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
+ position-visibility: anchors-visible;
+ }
+}
+
+.rst-caret, [rst-caret] { align-items: center; color: var(--rst-text-faint); display: inline-flex; font-size: 0.8em; transition: transform 0.15s; }
+.rst-caret svg, [rst-caret] svg, .rst-caret .icon, [rst-caret] .icon { display: block; height: 1em; width: 1em; }
+details[open] > summary > .rst-caret, details[open] > summary > [rst-caret] { transform: rotate(180deg); }
+
+/* ftok — an applied filter as a removable chip; the × is a plain link
+ to the unfiltered URL, so removing a filter is just navigation.
+ The × is 24px square because WCAG 2.2 AA 2.5.8 says a target is, and
+ it was 17px until the accessibility gate measured it. The chip lost
+ its block padding in the same breath to pay for most of that: the
+ chip measures 26.0px tall now against 22.4px before, and would have
+ been 29.2px if the padding had stayed. */
+.rst-ftok, [rst-ftok] { align-items: center; background: var(--rst-surface); border: 1px solid var(--rst-line-strong); border-radius: 7px; display: inline-flex; font-size: var(--rst-fs-xs); gap: 0.45rem; padding-block: 0; padding-inline: 0.6rem 0.2rem; }
+.rst-ftok .rst-ftok__k, [rst-ftok] [rst-ftok-k] { color: var(--rst-text-muted); }
+.rst-ftok a, [rst-ftok] a { align-items: center; border-radius: 4px; color: var(--rst-text-faint); display: inline-flex; height: 24px; justify-content: center; text-decoration: none; width: 24px; }
+.rst-ftok a:hover, [rst-ftok] a:hover { background: var(--rst-tone-negative-bg); color: var(--rst-tone-negative-fg); }
+
+/* Reduced motion: the caret's rotate transition is the only new
+ animation this batch adds. Everything else here (hover fills, colour
+ changes) is not a "large motion" trigger under 2.3.3, so it does not
+ need a rule here too. */
+@media (prefers-reduced-motion: reduce) {
+ .rst-caret, [rst-caret] { transition: none; }
+}
+
+/* ── The form family: field, switch, choice cards, seg-tabs, layout ── */
+
+/* fields — bare on the page, one column; a card in a form is reserved
+ for collections, toggle blocks, and choice cards. */
+.rst-field, [rst-field] { margin: var(--rst-sp-4) 0; }
+.rst-field__label, [rst-field-label] { color: var(--rst-text-muted); display: block; font-size: var(--rst-fs-sm); margin: 0 0 0.2rem; }
+.rst-field__hint, [rst-field-hint] { color: var(--rst-text-faint); font-weight: 400; }
+/* box-sizing is set here rather than in a `*` reset, because this file
+ promises exactly one collision surface with an app's own CSS: the
+ rst- prefix. Without it width:100% is the CONTENT box, so an input
+ overflowed its own wrapper by its padding and borders — 21px for a
+ plain field, 47px once .rst-dtp__input reserves room for the picker
+ button. That overflow was three of this batch's visible bugs at once:
+ an input running under the field beside it in a row, an input running
+ 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%; }
+.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,
+ and it is wide enough for the postcodes and codes it is for. The
+ max keeps it from overflowing a column narrower than itself. */
+.rst-input--short, [rst-input~="short"] { inline-size: 8rem; max-inline-size: 100%; }
+textarea.rst-input, textarea[rst-input] { min-height: 5rem; resize: vertical; }
+/* field-text/field-textarea (the interim pair the manifest generator
+ emits) mark a required label with * and put the textarea on its own
+ 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%; }
+.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
+ field-text/date/time/select family as <small>, and an inline <small>
+ silently drops the top margin — so the same message sat at two
+ different distances from its control depending on which partial drew
+ it. The hint's class is shared with the inline parenthetical inside a
+ label in the field partial, so only the <small> form is blocked out. */
+.rst-field__help, [rst-field-help] { color: var(--rst-text-muted); display: block; font-size: var(--rst-fs-xs); margin: 0.45rem 0 0; }
+.rst-field__error, [rst-field-error] { color: var(--rst-tone-negative-fg); display: block; font-size: var(--rst-fs-xs); margin: 0.45rem 0 0; }
+small.rst-field__hint, small[rst-field-hint] { display: block; font-size: var(--rst-fs-xs); margin: 0.45rem 0 0; }
+
+/* switch — the one toggle mechanism: real checkbox, visible track. The
+ knob's left: 2px is deliberately physical, not inset-inline-start: it
+ is the origin of a physical translateX, so the pair has to travel the
+ same axis in either writing mode or the knob leaves the track. What
+ that gives up: the knob therefore always travels left-to-right, so a
+ mirrored switch would need a direction-scoped rule of its own. */
+.rst-switch, [rst-switch] { align-items: center; cursor: pointer; display: inline-flex; gap: 0.6rem; position: relative; }
+.rst-switch input, [rst-switch] input { height: 1px; opacity: 0; position: absolute; width: 1px; }
+.rst-switch__track, [rst-switch-track] { background: var(--rst-line-strong); border-radius: 10px; flex: none; height: 19px; position: relative; transition: background 0.15s; width: 34px; }
+.rst-switch__track::after, [rst-switch-track]::after { background: var(--rst-surface); border-radius: 50%; box-shadow: var(--rst-shadow-knob); content: ""; height: 15px; left: 2px; position: absolute; top: 2px; transition: transform 0.22s cubic-bezier(0.22, 1, 0.36, 1); width: 15px; }
+.rst-switch:has(input:checked) .rst-switch__track, [rst-switch]:has(input:checked) [rst-switch-track] { background: var(--rst-accent); }
+.rst-switch:has(input:checked) .rst-switch__track::after, [rst-switch]:has(input:checked) [rst-switch-track]::after { transform: translateX(15px); }
+.rst-switch:has(input:focus-visible) .rst-switch__track, [rst-switch]:has(input:focus-visible) [rst-switch-track] { outline: 2px solid var(--rst-accent); outline-offset: 2px; }
+.rst-switch:has(input:disabled), [rst-switch]:has(input:disabled) { cursor: default; opacity: 0.55; }
+
+/* choice cards — whole-card target, native input visible. */
+.rst-choice, [rst-choice] { border: 0; margin: var(--rst-sp-4) 0; padding: 0; }
+.rst-choice > legend, [rst-choice] > legend { font-size: var(--rst-fs-sm); font-weight: 600; margin: 0 0 0.45rem; padding: 0; }
+.rst-choice__cards, [rst-choice-cards] { display: grid; gap: 0.5rem; }
+.rst-choice__cards label, [rst-choice-cards] label { align-items: flex-start; border: 1px solid var(--rst-line); border-radius: var(--rst-radius); cursor: pointer; display: flex; gap: 0.7rem; margin: 0; padding: 0.75rem 0.95rem; }
+.rst-choice__cards label:hover, [rst-choice-cards] label:hover { border-color: var(--rst-accent); }
+.rst-choice__cards input, [rst-choice-cards] input { accent-color: var(--rst-accent); flex: none; margin-top: 0.18rem; }
+.rst-choice__title, [rst-choice-title] { display: block; font-size: var(--rst-fs-sm); font-weight: 550; line-height: 1.35; }
+.rst-choice__desc, [rst-choice-desc] { color: var(--rst-text-muted); display: block; font-size: var(--rst-fs-xs); line-height: 1.4; }
+.rst-choice__cards label:has(input:checked), [rst-choice-cards] label:has(input:checked) { background: var(--rst-accent-soft); border-color: var(--rst-accent); }
+.rst-choice__cards label:has(input:focus-visible), [rst-choice-cards] label:has(input:focus-visible) { outline: 2px solid var(--rst-accent); outline-offset: 2px; }
+
+/* seg-tabs — filled pill inside a soft track; current is aria-current.
+ It wraps: width: max-content is what a segmented control wants and
+ max-inline-size: 100% is what WCAG 2.2 AA 1.4.10 requires, because
+ four tabs are 345px wide and a reflow viewport is 320. Two rows beats
+ a sideways scrollbar on the whole page. */
+.rst-seg-tabs, [rst-seg-tabs] { background: var(--rst-accent-soft); border-radius: 8px; display: flex; flex-wrap: wrap; gap: 2px; max-inline-size: 100%; padding: 2px; width: max-content; }
+.rst-seg-tabs a, [rst-seg-tabs] a { border-radius: var(--rst-radius-sm); color: var(--rst-text-muted); font-size: var(--rst-fs-sm); font-weight: 550; padding: 5px 16px; text-decoration: none; }
+.rst-seg-tabs a:hover, [rst-seg-tabs] a:hover { color: var(--rst-text); }
+.rst-seg-tabs a[aria-current], [rst-seg-tabs] a[aria-current] { background: var(--rst-surface); box-shadow: var(--rst-shadow-lift); color: var(--rst-text); font-weight: 600; }
+
+/* form layout — the interview rhythm and the save bar. */
+.rst-form-flow > .rst-field + .rst-field, [rst-form-flow] > [rst-field] + [rst-field] { margin-top: var(--rst-sp-5); }
+/* field-row aligns by the CONTROL row, not by the bottom of the box.
+ align-items:end was the bug: a field carrying an error is taller than
+ its sibling, so bottom-aligning it lifted its input clear of the row
+ and dropped its error line across the field beside it.
+
+ Judged against grid-with-subgrid, which does align label/control/
+ message tracks across siblings exactly: subgrid needs the row to be a
+ grid, and a grid cannot express what this row is for. grid-auto-flow:
+ column never wraps, so a narrow screen overflows instead of stacking;
+ and grid-auto-columns is one value for every column, so rst-grow — a
+ wide field beside a short one — cannot be said at all. Flex keeps
+ both. What align-items:start gives up is a row whose fields have
+ label boxes of different heights; the ::before below buys back the
+ common case of that (a field with no label at all), and a label long
+ enough to wrap is a field that wants its own row.
+
+ The message then flows under its own column and shifts no sibling,
+ which is the second half of the fix — it needs the border-box on
+ .rst-input above to hold, or the control overflows the column the
+ message is measured against. */
+.rst-field-row, [rst-field-row] { align-items: start; display: flex; flex-wrap: wrap; gap: 0.75rem; margin: 0.75rem 0; }
+.rst-field-row .rst-field, [rst-field-row] [rst-field] { margin: 0; }
+.rst-field-row > .rst-field, [rst-field-row] > [rst-field] { flex: 0 1 auto; min-inline-size: 8rem; }
+.rst-field-row > .rst-grow, [rst-field-row] > .rst-grow { flex: 1 1 12rem; }
+/* A field in a row with no label reserves the label's line anyway, so
+ its control still lines up with a labelled sibling's. It measures
+ that line rather than restating it: the same font-size .rst-field__label
+ declares, one lh of the line-height both of them inherit, and the
+ label's own bottom margin.
+
+ The two block-size declarations are one value with a fallback, not a
+ duplicate: an engine that has :has() but not the lh unit drops the
+ second line and keeps the calc. That window is real — :has() shipped
+ in Chrome 105 and Safari 15.4, lh in Chrome 109 and Safari 16.4 — so
+ Chrome 105-108 and Safari 15.4-16.3 render this rule and would
+ otherwise reserve a zero-height line. They get the body's 1.5
+ line-height as a number; everything newer measures the line itself
+ and the copy stops mattering. Firefox never sees the calc: lh landed
+ there in 120 and :has() in 121, so any Firefox that matches this
+ selector already has the unit. */
+.rst-field-row > .rst-field:not(:has(> .rst-field__label))::before, [rst-field-row] > [rst-field]:not(:has(> [rst-field-label]))::before { block-size: calc(var(--rst-fs-sm) * 1.5); block-size: 1lh; content: ""; display: block; font-size: var(--rst-fs-sm); margin-block-end: 0.2rem; }
+/* A row is sized by its controls, never by its messages: contain keeps a
+ long error out of the field's max-content width, so the sentence wraps
+ under its own control instead of stretching the column it belongs to
+ and squeezing the field beside it. */
+.rst-field-row > .rst-field > :is(.rst-field__help, .rst-field__error, .rst-field__hint), [rst-field-row] > [rst-field] > :is([rst-field-help], [rst-field-error], [rst-field-hint]) { contain: inline-size; }
+/* field-daterange — the fieldset reset the grouped start/end pair needs,
+ with the legend sized like a field label. Its own selector rather than
+ a share of .rst-choice: the two groups look alike today, and a card
+ tweak there must not silently reshape a date range. */
+.rst-field-range, [rst-field-range] { border: 0; margin: var(--rst-sp-4) 0; padding: 0; }
+.rst-field-range > legend, [rst-field-range] > legend { font-size: var(--rst-fs-sm); font-weight: 600; margin: 0 0 0.45rem; padding: 0; }
+.rst-field-range > .rst-field-row, [rst-field-range] > [rst-field-row] { margin: 0; }
+.rst-form-bar, [rst-form-bar] { align-items: center; background: var(--rst-bg); border-top: 1px solid var(--rst-line); bottom: 0; display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1.75rem; padding: 0.8rem 0; position: sticky; z-index: 5; }
+.rst-form-bar .rst-form-bar__note, [rst-form-bar] [rst-form-bar-note] { color: var(--rst-text-muted); font-size: var(--rst-fs-xs); margin-inline-end: auto; }
+.rst-form-actions, [rst-form-actions] { display: flex; gap: 0.5rem; justify-content: flex-end; }
+/* form/form-foot — the container the form-foot partial's callers wrap
+ fields in, and the closing action row form-foot itself emits. */
+/* One spacing mechanism, not two. The form is a flex column, so its
+ children's own stacking margins never collapse — they were added to
+ the gap, and they were also added at the two ends, where nothing is
+ being separated from anything. Inside a card holding a single field
+ that was the whole of the dead space under the control: 16px of
+ field margin on top of the card's own padding, at both ends. The gap
+ is now the rhythm (the same var(--rst-sp-5) .rst-form-flow uses
+ between its fields) and the blocks contribute no margin of their
+ own.
+
+ Stated as a rule over every child rather than as a list of the
+ classes we happen to ship: a list is a promise this file cannot keep,
+ because it cannot name .rst-callout's successor, and it cannot name
+ an app's own div at all — and a child the list forgot lands at gap
+ plus its own two margins, which is the bug this rule exists to
+ prevent. So: the form owns the block rhythm, full stop, and the two
+ deliberate exceptions are exempted here where they can be read.
+ .rst-form-foot and .rst-form-bar both set a block-start margin that
+ is not rhythm — it is the extra air that separates a closing action
+ row from the last question above it. */
+.rst-form, [rst-form] { display: flex; flex-direction: column; gap: var(--rst-sp-5); max-inline-size: 44rem; }
+.rst-form > *:not(.rst-form-foot, .rst-form-bar), [rst-form] > *:not([rst-form-foot], [rst-form-bar]) { margin-block: 0; }
+/* The closing action row the form-foot partial emits. It was
+ rst-form__foot until the markup flip (spec §6-v3): BEM's __ flattens
+ to a hyphen, so it and the sticky save bar above wanted the same
+ attribute, and a name the partial emits is the one that should own
+ the name the partial is called. The save bar, which no partial emits
+ and every caller writes by hand, is rst-form-bar now. */
+.rst-form-foot, [rst-form-foot] { display: flex; gap: var(--rst-sp-3); margin-block-start: var(--rst-sp-3); }
+
+@media (prefers-reduced-motion: reduce) {
+ .rst-switch__track, [rst-switch-track], .rst-switch__track::after, [rst-switch-track]::after { transition: none; }
+}
+
+/* ── The routes family: confirm, toggle-block, modal shells, bulk-bar,
+ help ────────────────────────────────────────────────────────── */
+
+/* toggle-block — a bordered card whose head is a switch; the body
+ reveals via :has(), zero JS. The switch is authoritative: the server
+ treats off as off, whatever the revealed (still-POSTed) fields say. */
+.rst-tblock, [rst-tblock] { background: var(--rst-surface); border: 1px solid var(--rst-line); border-radius: var(--rst-radius); margin: 0.7rem 0; overflow: hidden; }
+.rst-tblock > .rst-tblock__head, [rst-tblock] > [rst-tblock-head] { align-items: flex-start; cursor: pointer; display: flex; gap: 0.85rem; margin: 0; padding: 0.9rem 1.05rem; }
+.rst-tblock > .rst-tblock__head:hover, [rst-tblock] > [rst-tblock-head]:hover { background: var(--rst-accent-soft); }
+.rst-tblock .rst-tblock__title, [rst-tblock] [rst-tblock-title] { display: block; font-size: var(--rst-fs-sm); font-weight: 550; }
+.rst-tblock .rst-tblock__desc, [rst-tblock] [rst-tblock-desc] { color: var(--rst-text-muted); display: block; font-size: var(--rst-fs-xs); }
+.rst-tblock .rst-tblock__head input, [rst-tblock] [rst-tblock-head] input { height: 1px; opacity: 0; position: absolute; width: 1px; }
+.rst-tblock:has(.rst-tblock__head input:checked) .rst-switch__track, [rst-tblock]:has([rst-tblock-head] input:checked) [rst-switch-track] { background: var(--rst-accent); }
+.rst-tblock:has(.rst-tblock__head input:checked) .rst-switch__track::after, [rst-tblock]:has([rst-tblock-head] input:checked) [rst-switch-track]::after { transform: translateX(15px); }
+.rst-tblock:has(.rst-tblock__head input:focus-visible) .rst-switch__track, [rst-tblock]:has([rst-tblock-head] input:focus-visible) [rst-switch-track] { outline: 2px solid var(--rst-accent); outline-offset: 2px; }
+.rst-tblock > .rst-tblock__body, [rst-tblock] > [rst-tblock-body] { display: none; }
+.rst-tblock:has(.rst-tblock__head input:checked) > .rst-tblock__body, [rst-tblock]:has([rst-tblock-head] input:checked) > [rst-tblock-body] { background: var(--rst-surface-2); border-radius: 0 0 var(--rst-radius) var(--rst-radius); border-top: 1px solid var(--rst-line); display: block; padding: 0.9rem 1.05rem 1.05rem; }
+
+/* modal route — its own URL: the response renders the page you'll
+ return to inside an inert backdrop, then the overlay. Closing is a
+ plain link to that page. */
+.rst-backdrop, [rst-backdrop] { display: contents; }
+/* The scroll lock. It used to shift the whole page sideways as it
+ landed, because taking the scrollbar away takes its width away too;
+ --rst-scrollbar-gutter at the top of this file reserves that width
+ whether or not a scrollbar is in it, so the page now stays put. */
+body:has(.rst-backdrop), body:has([rst-backdrop]) { overflow: hidden; }
+.rst-modal-overlay, [rst-modal-overlay] { align-items: center; background: var(--rst-overlay); display: flex; inset: 0; justify-content: center; padding: 2rem 1rem; position: fixed; z-index: 10; }
+.rst-modal-panel, [rst-modal-panel] { background: var(--rst-bg); border: 1px solid var(--rst-line); border-radius: 14px; box-shadow: var(--rst-shadow-pop); display: flex; max-height: min(85vh, 640px); max-width: 860px; overflow: hidden; width: 100%; }
+/* The panel is a <dialog open>: the rendered-open, NON-modal dialog is
+ exactly what a modal-as-a-URL is — the server sends the page with the
+ dialog already open, and nothing calls showModal(). So ::backdrop is
+ never painted (it only exists for the top layer) and .rst-modal-overlay
+ stays the scrim, as it was. What the element does bring is a UA block
+ this idiom has to undo: absolute positioning with auto inline insets,
+ auto margins, 1em of padding and the Canvas colour pair. Reset them
+ and the panel lays out as the ordinary flex item it already was. */
+dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inherit; inset: auto; margin: 0; padding: 0; position: static; }
+.rst-modal-panel > nav, [rst-modal-panel] > nav { border-inline-end: 1px solid var(--rst-line); display: flex; flex: none; flex-direction: column; gap: 0.15rem; padding: 1.1rem 0.8rem; width: 180px; }
+.rst-modal-panel > nav a, [rst-modal-panel] > nav a { border-radius: 8px; color: inherit; padding: 0.4rem 0.65rem; text-decoration: none; }
+.rst-modal-panel > nav a:hover, [rst-modal-panel] > nav a:hover { background: var(--rst-accent-soft); }
+.rst-modal-panel > nav a[aria-current], [rst-modal-panel] > nav a[aria-current] { background: var(--rst-accent-soft); color: var(--rst-accent); font-weight: 600; }
+.rst-modal-panel > section, [rst-modal-panel] > section { flex: 1; min-width: 0; overflow-y: auto; padding: 1.5rem 2rem 2rem; position: relative; }
+.rst-modal-close, [rst-modal-close] { color: var(--rst-text-muted); font-size: 1.5rem; inset-inline-end: 1.1rem; line-height: 1; position: absolute; text-decoration: none; top: 1rem; }
+.rst-modal-close:hover, [rst-modal-close]:hover { color: inherit; }
+@media (max-width: 800px) {
+ .rst-modal-overlay, [rst-modal-overlay] { padding: 0.75rem; }
+ .rst-modal-panel, [rst-modal-panel] { flex-direction: column; max-height: 92vh; }
+ .rst-modal-panel > nav, [rst-modal-panel] > nav { border-bottom: 1px solid var(--rst-line); border-inline-end: 0; flex-direction: row; width: auto; }
+}
+
+/* help — a bordered ? icon-link to a section's help article: plain <a>,
+ new tab, CSS tooltip. The tooltip is NOT the accessible name — the
+ link must carry a full aria-label of its own. */
+.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: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. */
+.rst-bulkbar, [rst-bulkbar] { align-items: center; background: var(--rst-accent-soft); border-bottom: 1px solid var(--rst-line); display: flex; gap: 0.7rem; min-height: 46px; padding-block: 0; padding-inline: 0.6rem 0.85rem; }
+.rst-bulkbar__close, [rst-bulkbar-close] { align-items: center; border-radius: var(--rst-radius-sm); color: var(--rst-text-muted); display: inline-flex; height: 26px; justify-content: center; text-decoration: none; width: 26px; }
+.rst-bulkbar__close:hover, [rst-bulkbar-close]:hover { background: var(--rst-accent-soft); color: var(--rst-text); }
+.rst-bulkbar__close svg, [rst-bulkbar-close] svg, .rst-bulkbar__close .icon, [rst-bulkbar-close] .icon { height: 15px; width: 15px; }
+.rst-bulkbar__count, [rst-bulkbar-count] { font-size: var(--rst-fs-sm); font-weight: 600; }
+.rst-bulkbar__escalate, [rst-bulkbar-escalate] { color: var(--rst-accent); font-size: var(--rst-fs-xs); font-weight: 550; text-decoration: none; }
+.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; }
+.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; }
+
+.rst-back-nav, [rst-back-nav] { margin: 0 0 var(--rst-sp-3); }
+.rst-back-nav a, [rst-back-nav] a { color: var(--rst-text-muted); text-decoration: none; }
+.rst-back-nav a:hover, [rst-back-nav] a:hover { color: var(--rst-accent); }
+.rst-notice, [rst-notice] { background: var(--rst-tone-positive-bg); border-radius: var(--rst-radius-sm); color: var(--rst-tone-positive-fg); font-size: var(--rst-fs-sm); padding: 0.5rem 0.75rem; }
+.rst-form-error, [rst-form-error] { color: var(--rst-tone-negative-fg); font-size: var(--rst-fs-sm); }
+
+@media (prefers-reduced-motion: reduce) {
+ .rst-tip::after, [rst-tip]::after { transition: none; }
+}
+
+/* rst-spin — the working indicator a status page or busy button wears.
+ Pure CSS; reduced-motion users get a steady dimmed ring instead of
+ rotation. */
+.rst-spin, [rst-spin] {
+ display: inline-block;
+ width: 1em;
+ height: 1em;
+ border: 2px solid currentColor;
+ /* Physical on purpose: the gap in a ring that rotates a full turn
+ every 0.8s has no start or end side to mirror. */
+ border-right-color: transparent;
+ border-radius: 50%;
+ vertical-align: -0.15em;
+ animation: rst-spin 0.8s linear infinite;
+}
+@keyframes rst-spin { to { transform: rotate(1turn); } }
+@media (prefers-reduced-motion: reduce) {
+ .rst-spin, [rst-spin] { animation: none; opacity: 0.5; }
+}
+
+/* field-select's combobox, built by rastrillo.js past ten options. The
+ popup deliberately borrows .rst-dropdown__menu's shape so the two
+ overlays read as one system. Nothing here styles the native <select>:
+ it keeps .rst-input and is simply moved out of sight by .rst-sr-only. */
+.rst-combo, [rst-combo] { position: relative; }
+.rst-combo__list, [rst-combo-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-top: 4px; max-block-size: 15rem; overflow-y: auto; padding: 0.25rem; position: absolute; top: 100%; z-index: 30; }
+.rst-combo__option, [rst-combo-option] { border-radius: var(--rst-radius-sm); color: inherit; cursor: pointer; font-size: var(--rst-fs-sm); padding: 0.4rem 0.65rem; }
+.rst-combo__option:hover, [rst-combo-option]:hover, .rst-combo__option.is-active, [rst-combo-option].is-active { background: var(--rst-accent-soft); }
+/* The committed option is marked in the accessibility tree by
+ aria-selected; this is the matching visual, never the only signal. */
+.rst-combo__option[aria-selected="true"], [rst-combo-option][aria-selected="true"] { font-weight: 600; }
+/* A grouped <select> keeps its groups: each <optgroup> becomes an ARIA
+ group holding its own list of rows, under a heading that is furniture
+ only — the group's aria-label is what a screen reader announces.
+ Same faint uppercase recipe as .rst-lrow--head, for the same reason:
+ 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 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
+ a picker button anchored in the field and two kinds of row: the
+ reading of what was typed (with its Set affordance) and the quick
+ picks under it. The native input is not styled here either — it keeps
+ its value and its name and is simply moved out of sight by
+ .rst-sr-only, because it is what the form submits. */
+.rst-dtp, [rst-dtp] { position: relative; }
+.rst-dtp__input, [rst-dtp-input] { padding-inline-end: 2.25rem; }
+/* The button is anchored to .rst-dtp, which is exactly as wide and as
+ tall as the input it wraps once the input is a border box — so
+ inset-inline-end puts it at the control's inline end in either
+ writing mode, and inset-block:0 with auto block margins centres it on
+ the control at whatever height the type scale gives it. The old
+ inset-block-start:0.3rem was that centring measured once by hand
+ 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: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); }
+/* 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; }
+
+/* ── Shells: the page frames an app's layout template wraps .rst-page
+ in — topbar and sidebar. Written in logical properties throughout,
+ so a right-to-left locale gets a mirrored shell for free: the rail
+ moves to the inline end, the account menu with it, and nothing
+ here needs a second rule. ─────────────────────────────────────── */
+
+/* skip link — first in the DOM of every shell, out of sight until it
+ takes focus, then a real chip at the block/inline start. A keyboard
+ user must be able to jump the nav; this is the whole mechanism. */
+.rst-skip, [rst-skip] { block-size: 1px; clip-path: inset(50%); inline-size: 1px; overflow: hidden; position: absolute; white-space: nowrap; }
+.rst-skip:focus-visible, [rst-skip]:focus-visible { background: var(--rst-surface); block-size: auto; border: 1px solid var(--rst-accent); border-radius: var(--rst-radius-sm); clip-path: none; color: var(--rst-text); inline-size: auto; inset-block-start: var(--rst-sp-2); inset-inline-start: var(--rst-sp-2); overflow: visible; padding: 0.4rem 0.7rem; text-decoration: none; z-index: 60; }
+
+/* topbar shell — brand, nav and an account menu on one bar, the page
+ column beneath it, a footer under that. The account dropdown is
+ pushed to the inline end by auto margin, not by order or float. */
+.rst-shell-topbar .rst-shell__bar, [rst-shell-topbar] [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__brand, [rst-shell-brand] { color: inherit; font-weight: 650; text-decoration: none; }
+.rst-shell__nav, [rst-shell-nav] { display: flex; gap: var(--rst-sp-4); }
+.rst-shell__nav a, [rst-shell-nav] a { border-block-end: 2px solid transparent; color: var(--rst-text-muted); font-size: var(--rst-fs-sm); padding-block: 0.3rem; text-decoration: none; }
+.rst-shell__nav a:hover, [rst-shell-nav] a:hover { color: var(--rst-text); }
+.rst-shell__nav a[aria-current], [rst-shell-nav] a[aria-current] { border-block-end-color: var(--rst-accent); color: var(--rst-text); font-weight: 550; }
+.rst-shell__account, [rst-shell-account] { margin-inline-start: auto; }
+/* an account menu whose block was never overridden has an empty menu; hide the whole control rather than show a dead summary. */
+.rst-shell__account:has(> .rst-dropdown__menu:empty), [rst-shell-account]:has(> [rst-dropdown-menu]:empty) { display: none; }
+.rst-shell__foot, [rst-shell-foot] { border-block-start: 1px solid var(--rst-line); color: var(--rst-text-muted); font-size: var(--rst-fs-xs); padding-block: var(--rst-sp-4); padding-inline: var(--rst-sp-4); text-align: center; }
+/* the topbar shell emits a foot even when the block is un-overridden; an empty one is a stray rule and padding, so draw nothing. */
+.rst-shell__foot:empty, [rst-shell-foot]:empty { display: none; }
+
+/* The topbar's narrow layout. Below 800px — the same breakpoint the
+ sidebar collapses at, deliberately, because two shells with two
+ breakpoints is two systems — the bar's tail (nav, account, locale)
+ goes behind one disclosure carrying the `menu` icon. Above it the
+ summary is display: none and the tail is display: contents, so nav,
+ account and locale are direct flex items of the bar again and the
+ inline-end layout is byte-for-byte the one that shipped before this
+ existed. No script: a <details>, a media query and a pair of display
+ rules.
+
+ Why the tail is a SIBLING of the disclosure rather than its content,
+ which is the shape .rst-shell__chrome already uses: a closed
+ <details> hides its own content, and no CSS reliably un-hides it
+ across engines, so the wide layout could not be recovered from
+ inside one. It also keeps the account menu out of the disclosure
+ entirely, which matters — see the name below.
+
+ The name is rst-shell-menu and NOT rst-menus. <details name>
+ exclusivity is document-wide, not sibling-scoped, so a disclosure
+ sharing the menus' group would be closed by the very account menu it
+ reveals: the user clicks Account and the navigation they opened it
+ from disappears. Same rule this file's rst-menu-group already
+ documents ("a nested group MUST name a different one"), met here for
+ the first time in the framework's own shells. */
+.rst-shell-topbar .rst-shell__menu, [rst-shell-topbar] [rst-shell-menu] { margin-inline-start: auto; }
+.rst-shell-topbar .rst-shell__menu > summary, [rst-shell-topbar] [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; }
+.rst-shell-topbar .rst-shell__menu > summary::-webkit-details-marker, [rst-shell-topbar] [rst-shell-menu] > summary::-webkit-details-marker { display: none; }
+.rst-shell-topbar .rst-shell__tail, [rst-shell-topbar] [rst-shell-tail] { display: none; }
+/* The account's auto margin has to be undone here, and stretch does not
+ do it: in a column flex box the inline axis is the CROSS axis, and an
+ item with an auto cross-axis margin is exempt from stretching, so
+ margin-inline-start: auto survived and shoved Account to the trailing
+ edge of the collapsed stack — the same trailing-edge shove that made
+ the wrapped bar look wrong in the first place. The wide query below
+ puts it back. */
+.rst-shell-topbar .rst-shell__menu[open] + .rst-shell__tail, [rst-shell-topbar] [rst-shell-menu][open] + [rst-shell-tail] { display: flex; flex-basis: 100%; flex-direction: column; gap: var(--rst-sp-3); }
+.rst-shell-topbar .rst-shell__tail > .rst-shell__account, [rst-shell-topbar] [rst-shell-tail] > [rst-shell-account] { margin-inline-start: 0; }
+/* flex-start so a nav link's aria-current underline is as wide as its
+ own label rather than as wide as the bar, where it reads as a rule
+ across the menu instead of as a marker on one item. */
+.rst-shell-topbar .rst-shell__tail > .rst-shell__nav, [rst-shell-topbar] [rst-shell-tail] > [rst-shell-nav] { align-items: flex-start; flex-direction: column; gap: var(--rst-sp-2); }
+/* The nav links pick up the inline padding the two menu summaries below
+ them already carry, so the collapsed stack reads as one column of
+ labels rather than three components that happen to be above each
+ other. Same reason the rail's links carry 0.55rem. */
+.rst-shell-topbar .rst-shell__tail > .rst-shell__nav a, [rst-shell-topbar] [rst-shell-tail] > [rst-shell-nav] a { padding-inline: 0.6rem; }
+@media (min-width: 800px) {
+ .rst-shell-topbar .rst-shell__menu, [rst-shell-topbar] [rst-shell-menu] { display: none; }
+ /* Both selectors, and both at least as specific as the narrow rules
+ they undo: a media query adds no specificity, so the open
+ disclosure's 0-4-0 rule above would otherwise keep the tail a
+ column for anyone who opened the menu and then widened the
+ window. */
+ .rst-shell-topbar .rst-shell__tail,
+ [rst-shell-topbar] [rst-shell-tail],
+ .rst-shell-topbar .rst-shell__menu[open] + .rst-shell__tail,
+ [rst-shell-topbar] [rst-shell-menu][open] + [rst-shell-tail] { display: contents; }
+ .rst-shell-topbar .rst-shell__tail > .rst-shell__account, [rst-shell-topbar] [rst-shell-tail] > [rst-shell-account] { margin-inline-start: auto; }
+ .rst-shell-topbar .rst-shell__tail > .rst-shell__nav, [rst-shell-topbar] [rst-shell-tail] > [rst-shell-nav] { align-items: normal; flex-direction: row; gap: var(--rst-sp-4); }
+ .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. */
+.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.
+ The rail is the one box in the stylesheet whose height is the
+ VIEWPORT'S — block-size: 100dvh in the query below — and it also
+ carries padding, so as a content box its border box was 100dvh + 32px.
+ Sticky at inset-block-start: 0, the last 32px of it hung under the
+ window and clipped whatever sat at its foot; overflow-y: auto could
+ not help, because the content fitted the content box and no scrollbar
+ 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); }
+.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); }
+.rst-shell-sidebar > .rst-shell__chrome > summary::-webkit-details-marker, [rst-shell-sidebar] > [rst-shell-chrome] > summary::-webkit-details-marker { display: none; }
+.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; }
+.rst-shell-sidebar .rst-shell__nav a, [rst-shell-sidebar] [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-sidebar .rst-shell__nav a:hover, [rst-shell-sidebar] [rst-shell-nav] a:hover { background: var(--rst-accent-soft); color: var(--rst-text); }
+.rst-shell-sidebar .rst-shell__nav a[aria-current], [rst-shell-sidebar] [rst-shell-nav] a[aria-current] { background: var(--rst-accent-soft); color: var(--rst-accent); font-weight: 600; }
+/* The rail's foot — the language switcher and the person, in the corner
+ an app puts them in everywhere else. margin-block-start: auto is the
+ 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
+ TestTheSidebarRailPutsThePersonAtItsFootAndTheLanguageMenuOpensUpward
+ asserts the rail's own height 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; }
+ .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
+ <details> panel, opened block-start-ward instead of block-end-ward,
+ because below it there is only the window's edge. No script — this
+ is one pair of inset values. Only inside this query: below 800px
+ the rail is a content-height column with the whole page under it,
+ so the same menu opens downward there like every other menu in the
+ system. */
+ .rst-shell__rail-foot .rst-dropdown__menu, [rst-shell-rail-foot] [rst-dropdown-menu] { inset-block-end: calc(100% + 4px); inset-block-start: auto; margin-block-start: 0; }
+ /* Where anchor positioning is available the hand-written inset pair
+ has to be handed back, and not because it stopped being right: the
+ insets above are measured against .rst-dropdown's own box, and
+ position-area replaces that box with the region beside the anchor,
+ so calc(100% + 4px) would be 100% of the free space under the foot
+ — the panel launched up the rail. The flip does this case anyway
+ and does it by measuring: there is no room below the rail's foot,
+ so flip-block turns the menu upward on its own, and it turns back
+ down again in a layout where there IS room, which a fixed pair of
+ insets cannot. Specificity is why this is written here rather than
+ left to the generic rule: .rst-shell__rail-foot .rst-dropdown__menu
+ is (0,2,0) and would otherwise outrank it. */
+ @supports (position-area: block-end) and (position-try-fallbacks: flip-block) {
+ .rst-shell__rail-foot .rst-dropdown__menu, [rst-shell-rail-foot] [rst-dropdown-menu] { inset: auto; margin-block-start: 4px; }
+ }
+}
+
+/* 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.
+ Logical properties throughout, so an RTL locale mirrors it with no
+ second rule. */
+.rst-error, [rst-error] { margin-inline: auto; max-inline-size: 28rem; padding-block: 4rem var(--rst-sp-6); padding-inline: var(--rst-sp-4); text-align: center; }
+.rst-error__status, [rst-error-status] { color: var(--rst-text-faint); font-size: var(--rst-fs-xs); font-weight: 600; letter-spacing: 0.08em; margin: 0; }
+.rst-error__title, [rst-error-title] { color: var(--rst-text); font-size: 1.375rem; font-weight: 600; letter-spacing: -0.015em; line-height: 1.25; margin-block: var(--rst-sp-2) 0; }
+.rst-error__body, [rst-error-body] { color: var(--rst-text-muted); margin-block: var(--rst-sp-3) 0; }
+.rst-error__cta, [rst-error-cta] { display: flex; flex-wrap: wrap; gap: var(--rst-sp-2); justify-content: center; margin-block: var(--rst-sp-5) 0; }
+.rst-error__ref, [rst-error-ref] { color: var(--rst-text-faint); margin-block: 2rem 0; }
+
+/* ---------------------------------------------------------------------
+ Utilities, last on purpose.
+
+ .rst-sr-only competes with component rules of the same specificity —
+ both are single class selectors — so the cascade falls to source
+ order. Declared earlier it LOSES: .rst-input's width:100% beat its
+ inline-size:1px. Keep this block at the end of the file so the utility
+ wins wherever it is applied; a test asserts it stays here.
+
+ What that does NOT buy: a replaced element such as <select> has an
+ intrinsic minimum size, so inline-size:1px does not shrink it to 1px
+ whoever wins — measured at ~21px in Chromium. clip-path is what
+ actually hides these, and position:absolute is what keeps them out of
+ flow. Code that wants a genuinely minimal box (select.js does) should
+ drop the component class rather than rely on this.
+ --------------------------------------------------------------------- */
+
+/* Visually hidden, but in the DOM and the tab order — and it un-hides on
+ keyboard focus, because a focused control a sighted keyboard user
+ cannot see is a trap. */
+.rst-sr-only {
+ clip-path: inset(50%);
+ block-size: 1px;
+ inline-size: 1px;
+ overflow: hidden;
+ position: absolute;
+ white-space: nowrap;
+}
+.rst-sr-only:focus-visible {
+ background: var(--rst-accent);
+ border: 0;
+ border-radius: var(--rst-radius-sm);
+ block-size: auto;
+ clip-path: none;
+ color: var(--rst-on-accent);
+ cursor: pointer;
+ font: inherit;
+ font-size: var(--rst-fs-sm);
+ inline-size: auto;
+ overflow: visible;
+ padding: 0.3rem 0.6rem;
+ position: static;
+ white-space: normal;
+}
diff --git a/internal/sheets/templates/index.html b/internal/sheets/templates/index.html
new file mode 100644
index 0000000..17a1c9c
--- /dev/null
+++ b/internal/sheets/templates/index.html
@@ -0,0 +1,28 @@
+{{define "content"}}
+<header rst-page-header>
+<div rst-page-header-titles>
+<h1>Oficina Sheets</h1>
+<p rst-page-header-sub>A spreadsheet whose contents are sealed at rest.</p>
+</div>
+</header>
+
+{{if .SignedIn}}
+{{/* The signed-in state is the whole point of this page: a magic link
+ lands here, so if it cannot say who you are, a successful sign-in
+ and a silently failed one look identical. */}}
+<p>Signed in as <strong>{{.Address}}</strong>.</p>
+<p><a rst-btn="primary" href="/sheets">Open your sheets</a></p>
+{{else}}
+<p>You are not signed in. <a href="/signin">Sign in</a> — we email you a
+link instead of asking for a password.</p>
+{{end}}
+
+<div rst-callout>
+<div rst-callout-body>
+<p>A live host can read any sheet it has open. We never claim otherwise:
+the key lives in this instance's memory while it runs. What is sealed is
+everything at rest — no plaintext cell ever reaches the database file or
+object storage.</p>
+</div>
+</div>
+{{end}}
diff --git a/internal/sheets/templates/layout.html b/internal/sheets/templates/layout.html
new file mode 100644
index 0000000..e3e84e7
--- /dev/null
+++ b/internal/sheets/templates/layout.html
@@ -0,0 +1,61 @@
+{{define "layout"}}<!doctype html>
+{{/* data-theme is written into the first bytes of the response, from a
+ cookie read server-side. AGENTS.md: light/dark must resolve before
+ first paint, because cell fills resolve against the theme and a late
+ preference repaints every coloured cell. Never move this to a fetch
+ or to a script that runs after parse. */}}
+<html lang="en" dir="ltr" data-theme="{{.Theme}}">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{.Title}}</title>
+<link rel="stylesheet" href="{{asset "static/tokens.css"}}">
+<link rel="stylesheet" href="{{asset "static/theme.css"}}">
+<link rel="stylesheet" href="{{asset "static/sheets.css"}}">
+<script defer src="{{asset "static/rastrillo.js"}}"></script>
+{{iconAssets}}
+{{block "head" .}}{{end}}
+</head>
+<body>
+{{/* This shell is Sheets' own rather than rastrillo's topbar layout,
+ for one reason: the grid is a surface that must fill the viewport,
+ and the framework's shells wrap content in <main rst-page>, whose
+ page padding and max width are exactly right for every screen here
+ except that one. The "main" block below lets the grid page supply
+ its own <main>. Every attribute used is rastrillo's own, so the
+ chrome still matches the rest of the suite. */}}
+<div rst-shell-topbar>
+<a rst-skip href="#main">Skip to content</a>
+<header rst-shell-bar>
+<a rst-shell-brand href="/">Sheets</a>
+<div rst-shell-tail>
+<nav rst-shell-nav>
+{{if .SignedIn}}<a href="/sheets">Your sheets</a>{{end}}
+</nav>
+{{if .SignedIn}}
+<details rst-dropdown rst-shell-account name="rst-menus">
+<summary>{{.Address}}<span rst-caret aria-hidden="true">{{icon "chevron-down"}}</span></summary>
+<div rst-dropdown-menu>
+{{/* The theme switcher is a form, not a script. It is the one piece
+ of chrome that must work with JavaScript off, because a person
+ who needs a high-contrast theme is not well served by being told
+ to enable scripting first. */}}
+<form method="post" action="/theme">
+<input type="hidden" name="return" value="{{.Path}}">
+<button rst-btn="ghost" type="submit" name="theme" value="day">Day</button>
+<button rst-btn="ghost" type="submit" name="theme" value="plain">Plain</button>
+<button rst-btn="ghost" type="submit" name="theme" value="signal">Signal</button>
+</form>
+<form method="post" action="/signout">
+<button rst-btn="ghost" type="submit">Sign out</button>
+</form>
+</div>
+</details>
+{{end}}
+</div>
+</header>
+{{block "main" .}}<main rst-page id="main">{{template "content" .}}</main>{{end}}
+</div>
+</body>
+</html>
+{{end}}
diff --git a/internal/sheets/templates/signin.html b/internal/sheets/templates/signin.html
new file mode 100644
index 0000000..0732626
--- /dev/null
+++ b/internal/sheets/templates/signin.html
@@ -0,0 +1,34 @@
+{{define "content"}}
+<header rst-page-header>
+<div rst-page-header-titles>
+<h1>Sign in to Oficina Sheets</h1>
+</div>
+</header>
+
+{{/* Message is one of a fixed set of strings that signinMessage
+ SELECTS from a code in the query — never the code itself. There is
+ no path from the URL into this page. */}}
+{{with .Data.Message}}
+<div rst-callout><div rst-callout-body><p>{{.}}</p></div></div>
+{{end}}
+
+<form rst-form method="post" action="/signin">
+<div rst-field>
+<label rst-field-label for="address">Email address</label>
+<input rst-input id="address" name="address" type="email" autocomplete="email" required>
+</div>
+{{/* Force is the affordance auth asks for. When a keymail sign-in
+ fails, auth redirects to ?force=1&err=keymail specifically so this
+ page can offer the plain-email path: Begin reads a non-empty
+ "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 .Data.Force}}<input type="hidden" name="force" value="1">{{end}}
+<div rst-form-actions>
+<button rst-btn="primary" type="submit">Email me a link</button>
+</div>
+</form>
+
+<p rst-help>We email you a link instead of asking for a password. It signs
+you in once and then stops working.</p>
+{{end}}
diff --git a/internal/sheets/theme.go b/internal/sheets/theme.go
new file mode 100644
index 0000000..974b740
--- /dev/null
+++ b/internal/sheets/theme.go
@@ -0,0 +1,94 @@
+package sheets
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/rastrillo/ui"
+)
+
+// themeCookie carries the per-person theme.
+//
+// Per person, not per document (spec §9.3, AGENTS.md), so it is a
+// cookie on this origin rather than a column on Sheet. It is read
+// server-side on every request and interpolated into the page, which is
+// what lets the theme resolve before first paint.
+const themeCookie = "sheets_theme"
+
+// defaultTheme is the floor. rastrillo ships day, plain and signal;
+// anything else — a stale cookie, a hand-edited one, an attempt at path
+// traversal through a stylesheet href — resolves to this and never
+// reaches a template.
+const defaultTheme = "day"
+
+// themeCookieMaxAge is a year. A preference somebody set once should
+// not quietly expire back to the default a month later.
+const themeCookieMaxAge = 365 * 24 * 60 * 60
+
+// themeFrom reads the viewer's theme.
+//
+// The value is checked against the shipped theme names rather than
+// merely sanitised: it ends up in a data attribute and, in a design that
+// later switches stylesheets by name, in a URL. An allowlist of three
+// strings cannot be wrong in the way an escaping rule can.
+func themeFrom(r *http.Request) string {
+ c, err := r.Cookie(themeCookie)
+ if err != nil {
+ return defaultTheme
+ }
+ for _, name := range ui.ThemeNames() {
+ if c.Value == name {
+ return c.Value
+ }
+ }
+ return defaultTheme
+}
+
+// handleTheme sets the theme cookie and returns the person to where
+// they were.
+func (a *App) handleTheme(w http.ResponseWriter, r *http.Request) {
+ theme := r.FormValue("theme")
+ valid := false
+ for _, name := range ui.ThemeNames() {
+ if theme == name {
+ valid = true
+ break
+ }
+ }
+ if !valid {
+ http.Error(w, "Unknown theme.", http.StatusBadRequest)
+ return
+ }
+ http.SetCookie(w, &http.Cookie{
+ Name: themeCookie,
+ Value: theme,
+ Path: "/",
+ // Not HttpOnly: this is a display preference, not a
+ // credential, and a client-side theme toggle should be able to
+ // set it without a round trip when one is added later.
+ SameSite: http.SameSiteLaxMode,
+ Secure: r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https"),
+ MaxAge: themeCookieMaxAge,
+ Expires: time.Now().Add(themeCookieMaxAge * time.Second),
+ })
+ http.Redirect(w, r, safeReturn(r.FormValue("return")), http.StatusSeeOther)
+}
+
+// safeReturn validates a return path.
+//
+// A form field that becomes a Location header is an open redirect
+// unless it is constrained to a path on this origin. Anything with a
+// scheme, a host, or a leading "//" — which a browser reads as
+// scheme-relative and therefore off-site — is discarded for the root.
+func safeReturn(raw string) string {
+ if raw == "" || !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") {
+ return "/"
+ }
+ u, err := url.Parse(raw)
+ if err != nil || u.Scheme != "" || u.Host != "" {
+ return "/"
+ }
+ return u.RequestURI()
+}
diff --git a/internal/sheets/theme_test.go b/internal/sheets/theme_test.go
new file mode 100644
index 0000000..6c051ec
--- /dev/null
+++ b/internal/sheets/theme_test.go
@@ -0,0 +1,119 @@
+package sheets
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+func TestThemeDefaultsToDayWithNoCookie(t *testing.T) {
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ if got := themeFrom(r); got != defaultTheme {
+ t.Fatalf("themeFrom with no cookie = %q, want %q", got, defaultTheme)
+ }
+}
+
+func TestThemeReadsTheCookie(t *testing.T) {
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ r.AddCookie(&http.Cookie{Name: themeCookie, Value: "signal"})
+ if got := themeFrom(r); got != "signal" {
+ t.Fatalf("themeFrom = %q, want %q", got, "signal")
+ }
+}
+
+// The value is interpolated into a data attribute and, in a design that
+// later switches stylesheets by name, into a URL. An allowlist cannot
+// be wrong in the way an escaping rule can.
+func TestThemeRejectsAnUnknownValue(t *testing.T) {
+ for _, junk := range []string{"../../etc/passwd", "", "DAY", "day evil"} {
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
+ r.AddCookie(&http.Cookie{Name: themeCookie, Value: junk})
+ if got := themeFrom(r); got != defaultTheme {
+ t.Errorf("themeFrom with cookie %q = %q, want the %q floor", junk, got, defaultTheme)
+ }
+ }
+}
+
+// AGENTS.md: light/dark must resolve before first paint, because cell
+// fills resolve against the theme and a late preference repaints every
+// coloured cell. A page that carries its theme in its first byte cannot
+// flash; one that fetches it can. This test fails the moment somebody
+// moves the decision into JavaScript.
+func TestThemeIsInTheFirstResponseNotFetched(t *testing.T) {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.AddCookie(&http.Cookie{Name: themeCookie, Value: "signal"})
+ New(nil, nil, nil, nil).Router().ServeHTTP(rec, req)
+
+ if !strings.Contains(rec.Body.String(), `data-theme="signal"`) {
+ t.Fatal("the rendered page does not carry the theme; it would flash on load")
+ }
+}
+
+func TestPostThemeSetsTheCookieAndReturns(t *testing.T) {
+ form := url.Values{"theme": {"signal"}, "return": {"/sheets"}}
+ req := httptest.NewRequest(http.MethodPost, "/theme", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ New(nil, nil, nil, nil).Router().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST /theme = %d, want 303", rec.Code)
+ }
+ if loc := rec.Header().Get("Location"); loc != "/sheets" {
+ t.Fatalf("Location = %q, want /sheets", loc)
+ }
+ var found bool
+ for _, c := range rec.Result().Cookies() {
+ if c.Name == themeCookie && c.Value == "signal" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("no %s cookie was set: %v", themeCookie, rec.Result().Cookies())
+ }
+}
+
+func TestPostThemeRefusesAnUnknownTheme(t *testing.T) {
+ form := url.Values{"theme": {"neon"}}
+ req := httptest.NewRequest(http.MethodPost, "/theme", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ New(nil, nil, nil, nil).Router().ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("POST /theme with an unknown theme = %d, want 400", rec.Code)
+ }
+}
+
+// A form field that becomes a Location header is an open redirect
+// unless it is constrained to a path on this origin.
+func TestThemeReturnIsNotAnOpenRedirect(t *testing.T) {
+ for _, hostile := range []string{
+ "https://evil.example.com/",
+ "//evil.example.com/",
+ "http://evil.example.com",
+ "javascript:alert(1)",
+ } {
+ if got := safeReturn(hostile); got != "/" {
+ t.Errorf("safeReturn(%q) = %q, want %q", hostile, got, "/")
+ }
+ }
+ // A real in-app path with a query survives intact, or the switcher
+ // would drop people back at the root from every paginated view.
+ if got := safeReturn("/sheets/3/table?page=2"); got != "/sheets/3/table?page=2" {
+ t.Errorf("safeReturn dropped a legitimate path: %q", got)
+ }
+}
+
+// The theme switcher is chrome, and chrome that needs JavaScript is
+// chrome a person who needs a high-contrast theme cannot reach.
+func TestThemeSwitcherIsAForm(t *testing.T) {
+ app, cookies := signIn(t)
+ rec := do(t, app, as(httptest.NewRequest(http.MethodGet, "/", nil), cookies))
+ body := rec.Body.String()
+ if !strings.Contains(body, `action="/theme"`) {
+ t.Fatal("the signed-in page offers no theme form")
+ }
+}