oficina / sheets Public

Merge origin/main (shared chrome) into feat/k2-connectivity

Paul Campbell pushed by paul@keymail.dev 22354eff22c2f1deb8c88b0cd1922496e2168273 parent 82189daparent 556a7e8
64 files changed, +2585 −1258
  • cmd/sheets/main.go +4 −0
  • go.mod +2 −2
  • go.sum +4 −4
  • internal/guardstest/csstokens_test.go +31 −0
  • internal/guardstest/menugroup_test.go +52 −0
  • internal/guardstest/shellmenu_test.go +40 −46
  • internal/sheets/app.go +18 −6
  • internal/sheets/auth.go +1 −1
  • internal/sheets/begin.go +1 −1
  • internal/sheets/chrome.go +266 −0
  • internal/sheets/chrome_menu_test.go +235 −0
  • internal/sheets/chrome_signin_test.go +151 −0
  • internal/sheets/chrome_test.go +187 −0
  • internal/sheets/errors.go +271 −0
  • internal/sheets/errors_test.go +249 −0
  • internal/sheets/format/numeric.go +19 −6
  • internal/sheets/format/rounding_test.go +38 −0
  • internal/sheets/handlers_comments.go +3 −1
  • internal/sheets/handlers_history.go +9 −7
  • internal/sheets/handlers_import.go +2 −2
  • internal/sheets/handlers_import_page.go +2 −2
  • internal/sheets/handlers_link.go +3 −1
  • internal/sheets/handlers_rename.go +1 −1
  • internal/sheets/handlers_share.go +13 −1
  • internal/sheets/handlers_sheets.go +25 −22
  • internal/sheets/handlers_table.go +4 −4
  • internal/sheets/home.go +10 −3
  • internal/sheets/home_navigation_test.go +7 −2
  • internal/sheets/home_routes.go +2 −1
  • internal/sheets/home_test.go +8 −1
  • internal/sheets/jobpages.go +1 −1
  • internal/sheets/page.go +24 −1
  • internal/sheets/page_test.go +31 −6
  • internal/sheets/render.go +29 −44
  • internal/sheets/render_test.go +1 −1
  • internal/sheets/sharenotice.go +21 −0
  • internal/sheets/signin.go +150 −49
  • internal/sheets/signin_test.go +24 −14
  • internal/sheets/static/clipboard.js +6 −4
  • internal/sheets/static/menubar.js +8 −39
  • internal/sheets/static/modal.js +38 −0
  • internal/sheets/static/sheets.css +112 −280
  • internal/sheets/templates/comments.html +5 −5
  • internal/sheets/templates/error.html +6 −0
  • internal/sheets/templates/history.html +6 −6
  • internal/sheets/templates/index.html +10 −19
  • internal/sheets/templates/job.html +1 −1
  • internal/sheets/templates/layout.html +79 −114
  • internal/sheets/templates/link.html +1 −7
  • internal/sheets/templates/share.html +12 −15
  • internal/sheets/templates/sheet.html +107 −189
  • internal/sheets/templates/sheets.html +5 −25
  • internal/sheets/templates/signed_out.html +1 −0
  • internal/sheets/templates/signin.html +7 −42
  • internal/sheets/templates/table.html +7 −3
  • internal/sheets/theme.go +0 −132
  • internal/sheets/theme_test.go +71 −120
  • tools/drive/chrome.mjs +7 −6
  • tools/drive/clipboard.mjs +3 −3
  • tools/drive/drive.mjs +5 −5
  • tools/drive/drop.mjs +5 −4
  • tools/drive/menus.mjs +6 −6
  • tools/drive/modal.mjs +135 −0
  • tools/drive/tableformat.mjs +3 −3
diff --git a/cmd/sheets/main.go b/cmd/sheets/main.go
index a559c3f..d050593 100644
--- a/cmd/sheets/main.go
+++ b/cmd/sheets/main.go
@@ -117,6 +117,10 @@ func main() {
mux := http.NewServeMux()
mux.Handle("/", app.Router())
opts.Mux = mux
+ // A panic outside the router's signed-in group (where the app's own
+ // recoverer renders the viewer's chrome) is the same error page, not
+ // rastrillo's plain text (spec §3.3).
+ opts.ErrorPage = app.ErrorPage
// The app opened its own database handle above (db.Open already
// Pings eagerly, which satisfies Serve's boot-materialization duty
diff --git a/go.mod b/go.mod
index ff8afa0..bc8328a 100644
--- a/go.mod
+++ b/go.mod
@@ -3,8 +3,8 @@ module amadan.net/oficina/sheets
go 1.25.0
require (
- amadan.net/oficina/estilo.git v0.0.0-20260909234655-dec3908b237c
- amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f
+ amadan.net/oficina/estilo.git v0.0.0-20260924071126-894afdcb71a9
+ amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd
github.com/carlosframework/rastrillo v0.23.0
diff --git a/go.sum b/go.sum
index 45e2c16..0bb21ca 100644
--- a/go.sum
+++ b/go.sum
@@ -1,7 +1,7 @@
-amadan.net/oficina/estilo.git v0.0.0-20260909234655-dec3908b237c h1:LgE7WLm6zMG/qZScoAHb4X2gguzorctB4XGjPYavXts=
-amadan.net/oficina/estilo.git v0.0.0-20260909234655-dec3908b237c/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY=
-amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f h1:lkw3tjzHfZxe74pNfo+tGDz4mkZLg+D6R+FTkmsym6M=
-amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI=
+amadan.net/oficina/estilo.git v0.0.0-20260924071126-894afdcb71a9 h1:vHOkWeJ7mRwLjPbMihf0f7aqz4CLSe0/NseQsHcJUYQ=
+amadan.net/oficina/estilo.git v0.0.0-20260924071126-894afdcb71a9/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
+amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9 h1:K/PaOVdQqRMwifPD6ipiwLeGR5nbFSxm9FxjrDJlZcg=
+amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI=
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM=
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k=
amadan.net/rastrillo/native/server v0.0.0-20260923173546-2a5d07b2c8d3 h1:5EFx7BLJj/+G/rt8bTmeyMWVfF5s5Sns7ajTvX4w/I8=
diff --git a/internal/guardstest/csstokens_test.go b/internal/guardstest/csstokens_test.go
index dcbb4bf..93beef6 100644
--- a/internal/guardstest/csstokens_test.go
+++ b/internal/guardstest/csstokens_test.go
@@ -8,6 +8,9 @@ import (
"strings"
"testing"
+ "amadan.net/oficina/estilo.git/base"
+ "amadan.net/oficina/estilo.git/chrome"
+ estilomenu "amadan.net/oficina/estilo.git/menu"
"amadan.net/oficina/estilo.git/theme"
)
@@ -104,3 +107,31 @@ func dedupe(in []string) []string {
}
return out
}
+
+// TestEveryEstiloPropertyWeUseExists is the same check for the estilo
+// custom properties sheets.css reads (--estilo-float-radius and the
+// rest), against the three stylesheets that define them. The same
+// failure as a misspelled --rst- token: CSS takes the fallback in
+// silence, so a float drawn with an estilo property nobody defines is
+// simply not the suite's float.
+func TestEveryEstiloPropertyWeUseExists(t *testing.T) {
+ defined := map[string]bool{}
+ def := regexp.MustCompile(`(--estilo-[a-zA-Z0-9-]+)\s*:`)
+ for _, css := range [][]byte{base.CSS(), estilomenu.CSS(), chrome.CSS()} {
+ for _, m := range def.FindAllStringSubmatch(string(css), -1) {
+ defined[m[1]] = true
+ }
+ }
+ if len(defined) == 0 {
+ t.Fatal("guards: estilo defines no --estilo-* properties; this test is reading the wrong stylesheets")
+ }
+ use := regexp.MustCompile(`var\(\s*(--estilo-[a-zA-Z0-9-]+)`)
+ for _, rel := range ourStylesheets {
+ body := readRepoFile(t, repoRoot(t), rel)
+ for _, m := range use.FindAllStringSubmatch(body, -1) {
+ if !defined[m[1]] {
+ t.Errorf("%s: var(%s) is not defined by estilo's base, menu or chrome stylesheet", rel, m[1])
+ }
+ }
+ }
+}
diff --git a/internal/guardstest/menugroup_test.go b/internal/guardstest/menugroup_test.go
index 686730d..8994109 100644
--- a/internal/guardstest/menugroup_test.go
+++ b/internal/guardstest/menugroup_test.go
@@ -1,11 +1,14 @@
package guards
import (
+ "io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
+
+ "amadan.net/oficina/estilo.git/chrome"
)
// TestNoNestedExclusiveDetails is the guard on the one trap the menu
@@ -89,3 +92,52 @@ func checkNesting(t *testing.T, rel, body string, openTag, closeTag, named *rege
}
}
}
+
+// TestTheMenusShareOneGroup pins the design TestNoNestedExclusiveDetails
+// protects the edges of: every top-level menu on the grid — File, Edit,
+// Insert, Format, Data — and every trigger in the bar above it — Menu,
+// the switcher, the account — is one exclusive group, so opening File
+// closes the account menu and the other way round. Sheets chose that on
+// purpose when both halves were rastrillo dropdowns in "rst-menus"; they
+// are estilo's now, and the group is estilo's one name, "estilo-menus"
+// (shared chrome spec §7.1).
+//
+// Two halves because either can drift alone: the menubar is this app's
+// markup, the bar is estilo's.
+func TestTheMenusShareOneGroup(t *testing.T) {
+ const group = `name="estilo-menus"`
+ sheet := readRepoFile(t, repoRoot(t), "internal/sheets/templates/sheet.html")
+ titles := regexp.MustCompile(`<details estilo-menubar-menu[^>]*>`).FindAllString(sheet, -1)
+ if len(titles) < 5 {
+ t.Fatalf("found %d menubar menus in sheet.html, want File, Edit, Insert, Format and Data", len(titles))
+ }
+ for _, d := range titles {
+ if !strings.Contains(d, group) {
+ t.Errorf("sheet.html: %s is not in the one menu group", d)
+ }
+ }
+ for _, other := range regexp.MustCompile(`<details\b[^>]*\bname="([^"]+)"`).FindAllStringSubmatch(sheet, -1) {
+ if other[1] != "estilo-menus" && other[1] != "rst-tools" {
+ t.Errorf("sheet.html: a menu joins group %q; the menubar and the bar share estilo-menus, and only the toolbar's pickers have their own", other[1])
+ }
+ }
+
+ bar, err := fs.ReadFile(chrome.Templates(), "templates/bar.html")
+ if err != nil {
+ t.Fatal(err)
+ }
+ account, err := fs.ReadFile(chrome.Templates(), "templates/account.html")
+ if err != nil {
+ t.Fatal(err)
+ }
+ switcher, err := fs.ReadFile(chrome.Templates(), "templates/switcher.html")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for name, body := range map[string][]byte{"the Menu sheet": bar, "the account": account, "the switcher": switcher} {
+ d := regexp.MustCompile(`<details estilo-[a-z-]+[^>]*>`).FindString(string(body))
+ if d == "" || !strings.Contains(d, group) {
+ t.Errorf("estilo's %s trigger is %q, not in the one menu group", name, d)
+ }
+ }
+}
diff --git a/internal/guardstest/shellmenu_test.go b/internal/guardstest/shellmenu_test.go
index 19ae263..22d5472 100644
--- a/internal/guardstest/shellmenu_test.go
+++ b/internal/guardstest/shellmenu_test.go
@@ -6,63 +6,57 @@ import (
"regexp"
"strings"
"testing"
+
+ "amadan.net/oficina/estilo.git/chrome"
)
-// TestTheTopbarDisclosesItsTailOnNarrowScreens is the guard for a bug
-// that removed sign-out from every phone.
-//
-// rastrillo's topbar sets [rst-shell-tail] { display: none } and reveals
-// it from exactly two places: an [rst-shell-menu][open] that IMMEDIATELY
-// PRECEDES the tail, and a min-width: 800px media query. An app that
-// ships no [rst-shell-menu] therefore has no navigation and no account
-// menu below 800px — not collapsed behind a control, simply absent, with
-// no way for a person to get at them.
+// TestTheAccountIsReachableOnNarrowScreens is the guard for a bug that
+// once removed sign-out from every phone.
//
-// This is not a thing a Go test would otherwise notice. The markup is
-// valid, every handler works, the page renders, and the whole suite
-// passes; the affordance is missing only in a viewport narrower than the
-// one nobody was measuring in.
+// It used to pin rastrillo's topbar mechanics: [rst-shell-tail] is
+// display: none below 800px and only an [rst-shell-menu] immediately
+// before it could reveal it, so an app without that element had no
+// account menu — and no Sign out — on a phone. The bar is estilo's now
+// (shared chrome spec §5), and the same promise has a different shape:
+// below 800px the bar folds its nav and page actions into the Menu
+// sheet, and the account never folds at all. Sign out is two taps at
+// every width.
//
// Two assertions, because either alone is satisfiable while the bug is
-// present: the element must EXIST, and it must be the tail's immediately
-// previous sibling, since the framework's rule is a `+` combinator and
-// an element moved anywhere else in the bar restores the fault while
-// still reading as present.
-func TestTheTopbarDisclosesItsTailOnNarrowScreens(t *testing.T) {
- b, err := os.ReadFile(filepath.Join(repoRoot(t),
- "internal/sheets/templates/layout.html"))
+// back: the layout must render estilo's bar (and none of the old shell,
+// whose tail would hide an account put there), and estilo's narrow rule
+// must hide only the nav and the actions.
+func TestTheAccountIsReachableOnNarrowScreens(t *testing.T) {
+ b, err := os.ReadFile(filepath.Join(repoRoot(t), "internal/sheets/templates/layout.html"))
if err != nil {
t.Fatal(err)
}
- // Template comments are stripped FIRST, and that matters: the
- // layout explains this element in a {{/* ... */}} block that names
- // it, so a check run over the raw file passes on the prose alone
- // while the element itself is gone.
+ // Template comments are stripped FIRST: the layout explains the old
+ // shell in prose, and a check over the raw file would pass on it.
layout := regexp.MustCompile(`(?s)\{\{/\*.*?\*/\}\}`).ReplaceAllString(string(b), "")
-
- if !strings.Contains(layout, "rst-shell-menu") {
- t.Fatal("layout.html ships no [rst-shell-menu]: below 800px the " +
- "shell hides [rst-shell-tail] and nothing can reveal it, so " +
- "there is no navigation and no way to sign out on a phone")
+ if !strings.Contains(layout, `{{template "estilo-bar" .Frame.Chrome}}`) {
+ t.Fatal("layout.html does not render estilo's bar; whatever replaced it must keep the account reachable below 800px")
+ }
+ if strings.Contains(layout, "rst-shell-") {
+ t.Fatal("layout.html still writes rastrillo's topbar shell, whose tail hides everything in it below 800px")
}
- // Everything between the menu's close tag and the tail's open tag.
- // Template actions and comments are fine there; another ELEMENT is
- // not, because it breaks the `+` combinator.
- gap := regexp.MustCompile(`(?s)</details>(.*?)<div rst-shell-tail>`)
- m := gap.FindStringSubmatch(layout)
- if m == nil {
- t.Fatal("could not find [rst-shell-menu] followed by " +
- "[rst-shell-tail] in layout.html")
+ css := string(chrome.CSS())
+ narrow := regexp.MustCompile(`(?s)@media \(max-width: 799\.98px\)\s*\{(.*?)\n\}`).FindAllStringSubmatch(css, -1)
+ if len(narrow) == 0 {
+ t.Fatal("estilo's chrome.css has no narrow media query; this test is reading the wrong stylesheet")
}
- between := m[1]
- // Strip Go template actions ({{...}}, comments included) and
- // whitespace; what remains must hold no tag.
- between = regexp.MustCompile(`(?s)\{\{.*?\}\}`).ReplaceAllString(between, "")
- if strings.Contains(between, "<") {
- t.Errorf("an element sits between [rst-shell-menu] and "+
- "[rst-shell-tail]: rastrillo reveals the tail with a `+` "+
- "combinator, so only the immediately previous sibling works. "+
- "Found: %q", strings.TrimSpace(between))
+ for _, block := range narrow {
+ for _, rule := range regexp.MustCompile(`([^{}]+)\{([^{}]*)\}`).FindAllStringSubmatch(block[1], -1) {
+ if !strings.Contains(rule[2], "display: none") {
+ continue
+ }
+ for _, part := range strings.Split(rule[1], ",") {
+ sel := strings.TrimSpace(part)
+ if strings.Contains(sel, "estilo-account") || strings.Contains(sel, "estilo-switcher") || strings.Contains(sel, "estilo-bar-end") {
+ t.Errorf("estilo hides %q below 800px: the account (and Sign out) must never fold away", sel)
+ }
+ }
+ }
}
}
diff --git a/internal/sheets/app.go b/internal/sheets/app.go
index 2f70b3f..3b7f3ea 100644
--- a/internal/sheets/app.go
+++ b/internal/sheets/app.go
@@ -6,6 +6,8 @@ import (
nativeauth "amadan.net/rastrillo/native/server"
"net/http"
+ "amadan.net/oficina/estilo.git/brand"
+ "amadan.net/oficina/estilo.git/chrome"
"amadan.net/oficina/home/connect"
"github.com/carlosframework/rastrillo/sessions"
@@ -62,6 +64,9 @@ type App struct {
sealer *seal.Sealer
origin string
admit func(string) bool
+ // directory is the hub directory (HOME_DIRECTORY_ORIGIN), set only
+ // with Home configured: the app switcher's link. Empty, no switcher.
+ directory string
// hub fans every committed edit out to every other open tab. One per
// process, because it holds the live subscriptions: a hub per
@@ -168,9 +173,11 @@ func (a *App) Router() http.Handler {
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
- // Exact "/" only, not a catch-all: chi answers 404 for every
- // other path, which is what an operator hitting a wrong URL
- // should see (TestIndexIsNotACatchAll pins this).
+ // Exact "/" only, not a catch-all: every other unknown path is the
+ // 404 page for a browser and plain text for anything else
+ // (TestIndexIsNotACatchAll pins both halves).
+ r.NotFound(a.routeNotFound)
+ r.MethodNotAllowed(a.methodNotAllowed(r))
r.Get("/", a.handleIndex)
a.mountAuth(r)
if a.native != nil {
@@ -181,10 +188,13 @@ func (a *App) Router() http.Handler {
// http.FileServerFS semantics, where the URL path is "/" plus the
// FS path.
r.Handle("/static/*", staticHandler())
+ // estilo serves the suite's app icons (the bar's brand).
+ r.Handle(brand.Prefix+"*", brand.Handler())
// Light/dark is a per-person preference set by a form, so it works
- // with scripting off: somebody who needs one of them is badly
- // served by being told to enable JavaScript first.
- r.Post("/mode", a.handleMode)
+ // with scripting off. estilo's handler and cookie; inside the CSRF
+ // check above and outside the session group, so an expired session
+ // cannot turn the click into a sign-in loop (spec §8.3).
+ r.Post("/appearance", chrome.HandleAppearance)
// A share link is a capability, so this route is deliberately
// OUTSIDE the session group: no account, no member, no directory
@@ -193,6 +203,8 @@ func (a *App) Router() http.Handler {
r.Group(func(r chi.Router) {
r.Use(a.requireSession)
+ // After the guard, so a 500 shows the viewer's own chrome.
+ r.Use(a.recoverToErrorPage)
r.Get("/api/native/jobs/{id}", a.handleNativeJob)
r.Get("/sheets", a.handleSheetList)
r.Post("/sheets", a.handleSheetCreate)
diff --git a/internal/sheets/auth.go b/internal/sheets/auth.go
index 806dd83..3a28d68 100644
--- a/internal/sheets/auth.go
+++ b/internal/sheets/auth.go
@@ -166,7 +166,7 @@ func NewAuth(db *sql.DB, env AuthEnv, logger *slog.Logger, authorize func(string
// every magic-link click arrives on a page that exists to
// offer sign-in, and has to be told it worked.
SignedInPath: signedInPath,
- Subject: "Your Sheets sign-in link",
+ Subject: "Sign in to Sheets",
Logger: logger,
})
}
diff --git a/internal/sheets/begin.go b/internal/sheets/begin.go
index f9f200e..a66d910 100644
--- a/internal/sheets/begin.go
+++ b/internal/sheets/begin.go
@@ -30,7 +30,7 @@ func (a *App) handleBegin(w http.ResponseWriter, r *http.Request) {
// Parse before reading the field: Begin would do this itself, and
// this handler has to read the address before deciding to call it.
if err := r.ParseForm(); err != nil {
- http.Error(w, "bad form", http.StatusBadRequest)
+ a.badRequest(w, r, http.StatusBadRequest, "Malformed request.")
return
}
// App.Admits, NOT a.admit. The two must ask the same question as
diff --git a/internal/sheets/chrome.go b/internal/sheets/chrome.go
new file mode 100644
index 0000000..679f49b
--- /dev/null
+++ b/internal/sheets/chrome.go
@@ -0,0 +1,266 @@
+package sheets
+
+// The shared chrome (estilo chrome, round 2): every page's bar, account
+// menu and <title> are built here, once, so no page can drift from
+// another. Before this the bar was rastrillo's topbar markup written out
+// in layout.html, 45px signed out and 51px signed in (the Create and
+// Import buttons were taller than the bar), with a flat account list of
+// Light / Dark / System / Sign out and a title every handler spelled its
+// own way.
+//
+// Spec: estilo spec/shared-chrome, docs/superpowers/specs/
+// 2026-09-23-shared-chrome.md, §5 (bar), §6 (switcher), §8 (account),
+// §10 (titles). Sheets is the second adopter; the corrections it needed
+// are on estilo feat/r2-sheets-corrections.
+
+import (
+ "net/http"
+ "net/url"
+
+ "amadan.net/oficina/estilo.git/brand"
+ "amadan.net/oficina/estilo.git/chrome"
+ estilomenu "amadan.net/oficina/estilo.git/menu"
+ "amadan.net/oficina/home/connect"
+)
+
+// appName is the brand, the last part of every <title>, and the only
+// name the app goes by (spec §10: "Sheets", never "Oficina Sheets").
+const appName = "Sheets"
+
+// shortcutsDialog is the id of the Keyboard shortcuts dialog the layout
+// renders on every signed-in page, opened from the account menu's Help.
+const shortcutsDialog = "sheets-shortcuts"
+
+// Frame is what layout.html reads from every page: the bar, the parts of
+// the <title> before the app's name, the Appearance preference for
+// <html data-theme>, the shell variant, and the shortcut labels for this
+// request's platform.
+type Frame struct {
+ Chrome chrome.Bar
+ TitleParts []string
+ Theme string
+ // Shell is estilo-shell's value: "fill" on the grid, the one page
+ // that fills the viewport; empty everywhere else.
+ Shell string
+ // Keys are the shortcut labels, formatted server-side by estilo's
+ // menu.Shortcut for the request's platform (⌘Z on a Mac, Ctrl+Z
+ // elsewhere), so nothing flips after paint.
+ Keys map[string]string
+ // Shortcuts renders the Keyboard shortcuts dialog. It is set exactly
+ // when the account menu (which opens it) is.
+ Shortcuts bool
+}
+
+// frame describes one page's chrome. The zero value is a plain page with
+// no context, whose <title> is just "Sheets".
+type frame struct {
+ // parts precede "Sheets" in <title>, most specific first.
+ parts []string
+ // context is the open sheet's title; edit replaces it with the
+ // editable title when the person may rename.
+ context string
+ edit *chrome.TitleEdit
+ actions []chrome.Action
+ // public is a read-only page a stranger reaches (/l/): the brand is
+ // plain text, and "Sign in" carries no return path, because the path
+ // is a capability and must not be copied into another URL.
+ public bool
+ // signinPage is one of the sign-in, sent and signed-out pages: the
+ // corner is empty.
+ signinPage bool
+ // fill is the grid: the shell is the viewport and the bar does not
+ // stick (the document never scrolls).
+ fill bool
+ // ret, when set, is where Appearance returns to instead of this
+ // request's own path and query. The share screen sets it: its query
+ // is a one-shot notice, and reflecting a query into the page is the
+ // channel TestTheShareScreenIgnoresATokenInItsQuery keeps closed.
+ ret string
+}
+
+// titled is the frame for a page whose <title> is these parts.
+func titled(parts ...string) frame { return frame{parts: parts} }
+
+// onSheet is the frame for a page about one sheet: the sheet's title is
+// the bar's context, and page (when not empty) names the sub-page.
+func onSheet(s SheetSummary, page string) frame {
+ f := frame{context: s.Title, parts: []string{s.Title}}
+ if page != "" {
+ f.parts = []string{page, s.Title}
+ }
+ return f
+}
+
+// shortcutKeys are the chords the menus, toolbar tips and the shortcuts
+// dialog label. The bindings themselves live in commands.js and
+// clipboard.js, which accept either Ctrl or ⌘.
+var shortcutKeys = map[string]string{
+ "undo": "Mod+Z",
+ "cut": "Mod+X",
+ "copy": "Mod+C",
+ "paste": "Mod+V",
+ "filldown": "Mod+D",
+ "selectall": "Mod+A",
+ "bold": "Mod+B",
+ "italic": "Mod+I",
+ "delete": "Delete",
+ "enter": "Enter",
+ "tab": "Tab",
+ "esc": "Esc",
+}
+
+func keysFor(r *http.Request) map[string]string {
+ out := make(map[string]string, len(shortcutKeys))
+ for name, chord := range shortcutKeys {
+ out[name] = estilomenu.Shortcut(r, chord)
+ }
+ return out
+}
+
+// frameFor builds a page's Frame. The viewer is read optionally: an
+// error page, the link page and a sign-in page all show whatever the
+// request's session says, without requiring one.
+func (a *App) frameFor(r *http.Request, o frame) Frame {
+ bar := chrome.Bar{
+ App: chrome.App{
+ Name: appName,
+ Slug: "sheets",
+ Home: "/",
+ Icon: brand.Path("sheets"),
+ },
+ Context: o.context,
+ ContextEdit: o.edit,
+ Actions: o.actions,
+ Sticky: !o.fill,
+ Labels: chrome.BarLabels{Skip: "Skip to content", Menu: "Menu"},
+ }
+ address, signedIn := "", false
+ if !o.signinPage {
+ address, signedIn = a.identity(r)
+ }
+ switch {
+ case o.signinPage:
+ // The corner is empty on the pages that are themselves sign-in.
+ case signedIn:
+ // The list, not "/", which only redirects a signed-in person
+ // there: one hop fewer from the brand.
+ bar.App.Home = "/sheets"
+ bar.Account = a.account(r, address, o.ret)
+ bar.Switcher = a.switcher()
+ case o.public:
+ bar.SignIn = &chrome.Link{Href: "/signin", Label: "Sign in"}
+ default:
+ bar.SignIn = &chrome.Link{Href: "/signin?return_to=" + url.QueryEscape(returnTarget(r)), Label: "Sign in"}
+ }
+ if o.public {
+ bar.App.Home = ""
+ }
+ f := Frame{
+ Chrome: bar,
+ TitleParts: o.parts,
+ Theme: chrome.ThemeAttr(chrome.Appearance(r)),
+ Keys: keysFor(r),
+ Shortcuts: bar.Account != nil,
+ }
+ if o.fill {
+ f.Shell = "fill"
+ }
+ return f
+}
+
+// returnTarget is where a person should come back to: this page, if it
+// is a page (a GET), otherwise the list.
+func returnTarget(r *http.Request) string {
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ return "/sheets"
+ }
+ return r.URL.RequestURI()
+}
+
+// account is the account menu (spec §8.2): who is signed in, "Connect
+// your Oficina account" when that means something, Appearance, Help, and
+// the one Sign out in the app.
+func (a *App) account(r *http.Request, address, ret string) *chrome.Account {
+ if ret == "" {
+ ret = returnTarget(r)
+ }
+ acc := &chrome.Account{
+ TriggerLabel: "Account",
+ Initials: chrome.Initials("", address),
+ Address: address,
+ Labels: chrome.AccountLabels{SignedInAs: "Signed in as", Appearance: "Appearance", Help: "Help"},
+ Appearance: &chrome.AppearanceMenu{
+ Action: "/appearance",
+ Return: ret,
+ Options: chrome.AppearanceOptions(chrome.Appearance(r), "Light", "Dark", "System"),
+ },
+ // Sheets has no Open source page yet, so Help holds only the
+ // shortcuts; the spec's "Open source last" waits for a credits
+ // page (recorded on the branch discussion).
+ Help: []estilomenu.Item{{Dialog: shortcutsDialog, Label: "Keyboard shortcuts…"}},
+ SignOut: chrome.SignOut{Action: "/signout", Label: "Sign out"},
+ }
+ if address != "" {
+ acc.TriggerLabel = "Account: " + address
+ }
+ if a.offersHomeLink(r) {
+ acc.Items = []estilomenu.Item{{Href: "/account/home", Label: homeCopy["menu_action"]}}
+ }
+ return acc
+}
+
+// offersHomeLink is whether the account menu offers to connect the
+// person's Oficina account: only when Home is configured, and never to
+// somebody who signed in through Home, whose account is already the
+// connection (spec §12.1 item 4; before this Sheets offered it to Home
+// sessions too).
+func (a *App) offersHomeLink(r *http.Request) bool {
+ if a.home == nil {
+ return false
+ }
+ s, ok := a.homeSessions.From(r)
+ return ok && s.Method != connect.HomeMethod
+}
+
+// switcher is the app switcher, or nil when no hub directory is
+// configured. It shows "All apps" alone and no tiles (Paul's §13 answer
+// 2): a tile today still costs an assertion exchange through the
+// directory session.
+func (a *App) switcher() *chrome.Switcher {
+ if a.directory == "" {
+ return nil
+ }
+ return &chrome.Switcher{
+ Label: "Oficina apps",
+ CurrentLabel: "(this app)",
+ AllApps: estilomenu.Item{Href: a.directory + "/", Label: "All apps"},
+ }
+}
+
+// homeCopy is connect's account-connection copy with the hub unnamed
+// (Paul's §13 answer 3): "your Oficina account", and the verb Connect.
+var homeCopy = func() map[string]string {
+ out := make(map[string]string, len(connect.English))
+ for k, v := range connect.English {
+ out[k] = v
+ }
+ out["menu_action"] = "Connect your Oficina account"
+ out["start_title"] = "Connect your Oficina account"
+ out["start_body"] = "Use your Oficina account to sign in to this account. Your sheets and sharing settings stay as they are."
+ out["start_action"] = "Continue"
+ out["confirm_body"] = "Connect the account below to the Oficina account you just signed in to."
+ out["unknown_title"] = "Oficina account not connected"
+ out["unknown_body"] = "Sign in to your existing account, then connect your Oficina account from the account menu."
+ out["success_body"] = "You can now use your Oficina account to sign in to this account."
+ out["signedout_body"] = "You’ve signed out of Sheets on this device."
+ out["signedout_action"] = "Sign in"
+ out["documents_action"] = "Back to Sheets"
+ return out
+}()
+
+// pageHeaders marks a rendered page as depending on the platform its
+// shortcut labels were formatted for: menu.Shortcut reads the
+// User-Agent, so a shared cache must not hand a Mac's page to Windows.
+func pageHeaders(w http.ResponseWriter) {
+ w.Header().Add("Vary", "User-Agent")
+}
diff --git a/internal/sheets/chrome_menu_test.go b/internal/sheets/chrome_menu_test.go
new file mode 100644
index 0000000..69811b2
--- /dev/null
+++ b/internal/sheets/chrome_menu_test.go
@@ -0,0 +1,235 @@
+package sheets
+
+import (
+ "html"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+// §5.4: the rename is the bar's editable title — a real form posting to
+// the rename route, only for someone who may write. A reader sees the
+// title read-only.
+func TestTheBarRenamesTheSheet(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+ body := page(t, app, cookies, "/sheets/"+strconv.FormatUint(s.ID, 10)).Body.String()
+ for _, want := range []string{
+ `<form estilo-bar-title method="post" action="/sheets/` + strconv.FormatUint(s.ID, 10) + `/rename">`,
+ `<input estilo-bar-title-input name="title" value="Budget" aria-label="Sheet title"`,
+ `<button rst-btn="ghost" type="submit" estilo-bar-title-submit>Rename</button>`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("the writer's bar lacks %s", want)
+ }
+ }
+ if strings.Contains(body, "data-doc-title") {
+ t.Error("the old in-bar rename form is still rendered")
+ }
+ // A reader: a viewer grant only, signed in through the real link.
+ if _, err := app.grant(s.ID, "reader@example.com", SheetViewer, signedInMember(t, app)); err != nil {
+ t.Fatal(err)
+ }
+ postSignin(t, app, "reader@example.com")
+ u, err := url.Parse(app.testMailer(t).lastLink(t))
+ if err != nil {
+ t.Fatal(err)
+ }
+ reader := do(t, app, httptest.NewRequest(http.MethodGet, u.RequestURI(), nil)).Result().Cookies()
+ body = page(t, app, reader, "/sheets/"+strconv.FormatUint(s.ID, 10)).Body.String()
+ if strings.Contains(body, "estilo-bar-title") || !strings.Contains(body, `<span estilo-bar-context dir="auto" title="Budget">Budget</span>`) {
+ t.Error("a reader is offered the rename, or does not see the title")
+ }
+}
+
+// The rename works through the bar with scripting off: Enter posts the
+// form, and the sheet comes back renamed.
+func TestTheBarsRenameFormRenames(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+ rec := do(t, app, as(postForm("/sheets/"+strconv.FormatUint(s.ID, 10)+"/rename", map[string][]string{"title": {"Commission Q2"}}), cookies))
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("rename = %d", rec.Code)
+ }
+ if body := page(t, app, cookies, "/sheets/"+strconv.FormatUint(s.ID, 10)).Body.String(); !strings.Contains(body, `value="Commission Q2"`) || titleOf(t, body) != "Commission Q2 · Sheets" {
+ t.Error("the bar does not show the new title")
+ }
+}
+
+// §0.5 and §12.1 item 2: <meta charset> first, then the stylesheets in
+// the suite's order — tokens, theme, brand, estilo base, menu, chrome,
+// then Sheets' own.
+func TestEstiloChromeAssetsAreLinkedInOrder(t *testing.T) {
+ body := page(t, New(Config{}), nil, "/signin").Body.String()
+ head := body[strings.Index(body, "<head>"):strings.Index(body, "</head>")]
+ if !strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(head, "<head>")), `<meta charset="utf-8">`) {
+ t.Error("<meta charset> is not the first thing in <head>")
+ }
+ last := -1
+ for _, css := range []string{"/static/tokens.", "/static/theme.", "/static/brand/brand.", "/static/estilo/base.", "/static/estilo/menu.", "/static/estilo/chrome.", "/static/sheets."} {
+ i := strings.Index(head, css)
+ if i < 0 || i < last {
+ t.Fatalf("%s is missing or out of order", css)
+ }
+ last = i
+ }
+ for _, asset := range []string{"/static/estilo/base.", "/static/estilo/chrome."} {
+ href := regexp.MustCompile(regexp.QuoteMeta(asset) + `[0-9a-f]+\.(css|js)`).FindString(head)
+ if href == "" {
+ t.Fatalf("%s is not fingerprinted", asset)
+ }
+ if rec := do(t, New(Config{}), httptest.NewRequest(http.MethodGet, href, nil)); rec.Code != http.StatusOK || rec.Body.Len() == 0 {
+ t.Errorf("%s does not serve (%d)", href, rec.Code)
+ }
+ }
+ if rec := do(t, New(Config{}), httptest.NewRequest(http.MethodGet, "/static/estilo/brand/sheets.svg", nil)); rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "image/svg+xml" {
+ t.Errorf("the bar's brand icon does not serve (%d)", rec.Code)
+ }
+}
+
+// §7.3: shortcuts are labelled for the request's platform, server-side,
+// in the menus, the toolbar's tips and the shortcuts dialog alike — and
+// the page says it varies by User-Agent.
+func TestShortcutLabelsFollowThePlatform(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+ path := "/sheets/" + strconv.FormatUint(s.ID, 10)
+ for ua, want := range map[string][]string{
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36": {`<kbd estilo-menu-key>⌘Z</kbd>`, `data-tip="Undo (⌘Z)"`, `data-tip="Bold (⌘B)"`, `<dt><kbd>⌘C</kbd></dt>`, `data-key-copy="⌘C"`},
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36": {`<kbd estilo-menu-key>Ctrl+Z</kbd>`, `data-tip="Undo (Ctrl+Z)"`, `data-tip="Bold (Ctrl+B)"`, `<dt><kbd>Ctrl+C</kbd></dt>`, `data-key-copy="Ctrl+C"`},
+ } {
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ req.Header.Set("User-Agent", ua)
+ rec := do(t, app, as(req, cookies))
+ // html/template writes "+" as &#43;; the browser reads it back.
+ body := html.UnescapeString(rec.Body.String())
+ for _, w := range want {
+ if !strings.Contains(body, w) {
+ t.Errorf("%s: missing %s", ua[13:30], w)
+ }
+ }
+ for _, stale := range []string{"Ctrl Z", "(Ctrl+Z)</", "Ctrl + C"} {
+ if strings.Contains(body, stale) {
+ t.Errorf("%s: an old label %q survives", ua[13:30], stale)
+ }
+ }
+ if !strings.Contains(strings.Join(rec.Header().Values("Vary"), ","), "User-Agent") {
+ t.Error("a page with platform labels does not Vary on User-Agent")
+ }
+ }
+}
+
+// §7.3: an item appears only if it works in this build. The Help menu
+// (a list of keys, not a menu) left the menu bar for the account menu's
+// "Keyboard shortcuts…" dialog; Format keeps no inline submenus; items
+// that need script are hidden until it runs.
+func TestUnbuiltMenuItemsAreHidden(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+ body := page(t, app, cookies, "/sheets/"+strconv.FormatUint(s.ID, 10)).Body.String()
+ menubar := body[strings.Index(body, "<nav estilo-menubar"):]
+ menubar = menubar[:strings.Index(menubar, "</nav>")]
+ titles := regexp.MustCompile(`<summary estilo-menubar-title>([^<]+)</summary>`).FindAllStringSubmatch(menubar, -1)
+ var got []string
+ for _, m := range titles {
+ got = append(got, m[1])
+ }
+ if strings.Join(got, ",") != "File,Edit,Insert,Format,Data" {
+ t.Errorf("menu bar titles %v, want File, Edit, Insert, Format, Data", got)
+ }
+ if strings.Count(menubar, "<details") != len(titles) {
+ t.Error("a menu holds a submenu; menus here are one level")
+ }
+ for _, gone := range []string{"rst-dropdown", "rst-menu-group", "Fill colour", "Number format"} {
+ if strings.Contains(menubar, gone) {
+ t.Errorf("the menu bar still carries %q", gone)
+ }
+ }
+ for _, cmd := range regexp.MustCompile(`<button estilo-menu-item type="button" data-cmd="(cut|copy|paste|clear|fill-down|fill-right|select-all|fn)"[^>]*>`).FindAllString(menubar, -1) {
+ if !strings.Contains(cmd, " hidden") {
+ t.Errorf("%s is shown before the script that runs it", cmd)
+ }
+ }
+ // With scripting off Edit holds Undo alone, so its separators are
+ // script-only too: no rule above an empty group.
+ edit := menubar[strings.Index(menubar, "<summary estilo-menubar-title>Edit</summary>"):]
+ edit = edit[:strings.Index(edit, "</details>")]
+ if n, hidden := strings.Count(edit, "<hr"), strings.Count(edit, "<hr data-needs-js hidden>"); n == 0 || n != hidden {
+ t.Errorf("Edit has %d separators, %d of them hidden until script: with scripting off they rule off empty groups", n, hidden)
+ }
+ // The shortcuts moved to the account menu, as a dialog that exists.
+ if !strings.Contains(body, `<button estilo-menu-item type="button" commandfor="sheets-shortcuts" command="show-modal"><span estilo-menu-label>Keyboard shortcuts…</span></button>`) {
+ t.Error("the account menu's Help does not open the shortcuts")
+ }
+ if !strings.Contains(body, `<dialog id="sheets-shortcuts"`) {
+ t.Error("the shortcuts dialog the account menu opens is not on the page")
+ }
+}
+
+// §4: the only notice vocabulary is rastrillo's callout with a real tone.
+// The grep gate: no rst-notice, no valued rst-callout="…" (the old
+// "warn" matched nothing and painted grey), no rst-tone="warn".
+func TestTemplatesUseOnlyCalloutTones(t *testing.T) {
+ files, err := filepath.Glob("templates/*.html")
+ if err != nil || len(files) == 0 {
+ t.Fatal("no templates found", err)
+ }
+ strip := regexp.MustCompile(`(?s)\{\{/\*.*?\*/\}\}`)
+ bad := regexp.MustCompile(`rst-notice|rst-callout="|rst-tone="warn"`)
+ for _, f := range files {
+ body, err := os.ReadFile(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if m := bad.FindString(strip.ReplaceAllString(string(body), "")); m != "" {
+ t.Errorf("%s uses %q", f, m)
+ }
+ }
+ // And the tones reach the page: a created link's warning, a grant's
+ // positive notice, a refused grant's negative alert.
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+ share := "/sheets/" + strconv.FormatUint(s.ID, 10) + "/share"
+ for query, want := range map[string]string{
+ "?notice=shared": `rst-tone="positive"`,
+ "?notice=bad-role": `rst-tone="negative" role="alert"`,
+ } {
+ if body := page(t, app, cookies, share+query).Body.String(); !strings.Contains(body, want) {
+ t.Errorf("%s: no %s callout", query, want)
+ }
+ }
+ rec := do(t, app, as(postForm(share+"/link", map[string][]string{"role": {"viewer"}}), cookies))
+ if body := rec.Body.String(); !strings.Contains(body, `<div rst-callout rst-tone="warning">`) || strings.Count(body, "Copy it now") != 1 {
+ t.Errorf("a new link is not one warning callout (status %d)", rec.Code)
+ }
+}
+
+// While a modal dialog is open no page listener may hear a key: static/
+// modal.js stops key and clipboard events at the window, in the capture
+// phase, and it has to be the first script so its listener is registered
+// before the grid library's (Astra, #39 finding 1; the browser proof is
+// tools/drive/modal.mjs, Chromium and WebKit).
+func TestTheModalGuardIsTheFirstScript(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+ body := page(t, app, cookies, "/sheets/"+strconv.FormatUint(s.ID, 10)).Body.String()
+ first := regexp.MustCompile(`<script[^>]*src="([^"]+)"`).FindStringSubmatch(body)
+ if first == nil || !strings.HasPrefix(first[1], "/static/modal.") {
+ t.Fatalf("the first script is %v, want static/modal.js", first)
+ }
+ js, err := staticFiles().(interface{ ReadFile(string) ([]byte, error) }).ReadFile("static/modal.js")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{"window.addEventListener(type", "stopImmediatePropagation()", "}, true);", "'keydown'", "'paste'", "dialog:modal"} {
+ if !strings.Contains(string(js), want) {
+ t.Errorf("modal.js lost %q", want)
+ }
+ }
+}
diff --git a/internal/sheets/chrome_signin_test.go b/internal/sheets/chrome_signin_test.go
new file mode 100644
index 0000000..1e8dea5
--- /dev/null
+++ b/internal/sheets/chrome_signin_test.go
@@ -0,0 +1,151 @@
+package sheets
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+// §8.2 item 2: "Connect your Oficina account" is offered with Home
+// configured, to a local session, and never to a Home session, whose
+// account is already the connection. Sheets used to offer it to both.
+func TestConnectYourOficinaAccountIsHiddenForHomeSessions(t *testing.T) {
+ f := newHomeFixture(t)
+ f.local(t, signedInAddress)
+ if body := f.request(t, "GET", "/sheets", nil, false).Body.String(); !strings.Contains(body, `<a estilo-menu-item href="/account/home"><span estilo-menu-label>Connect your Oficina account</span></a>`) {
+ t.Error("a local session is not offered the connection")
+ }
+ g := newHomeFixture(t)
+ homeSignedIn(t, g)
+ body := g.request(t, "GET", "/sheets", nil, false).Body.String()
+ if !strings.Contains(body, "<details estilo-account") {
+ t.Fatal("the Home session is not signed in")
+ }
+ if strings.Contains(body, "/account/home") || strings.Contains(body, "Home account") {
+ t.Error("a Home session is offered to connect the account it signed in with")
+ }
+ app, cookies := signIn(t)
+ if body := page(t, app, cookies, "/sheets").Body.String(); strings.Contains(body, "/account/home") {
+ t.Error("a standalone instance offers a connection to a Home it does not have")
+ }
+}
+
+// Home #74, decision 2: with Home configured, a signed-out "/" starts
+// sign-in instead of showing a landing page — the switcher's single-team
+// hop lands on the root. Signed in, it is the list, both ways.
+func TestTheRootStartsSignInWhenHomeIsConfigured(t *testing.T) {
+ f := newHomeFixture(t)
+ if rec := f.request(t, "GET", "/", nil, false); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?return_to=%2F" {
+ t.Fatalf("signed-out / with Home = %d %q, want 303 /signin?return_to=%%2F", rec.Code, rec.Header().Get("Location"))
+ }
+ homeSignedIn(t, f)
+ if rec := f.request(t, "GET", "/", nil, false); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/sheets" {
+ t.Fatalf("signed-in / with Home = %d %q, want 303 /sheets", rec.Code, rec.Header().Get("Location"))
+ }
+ g := newHomeFixture(t)
+ g.local(t, signedInAddress)
+ if rec := g.request(t, "GET", "/", nil, false); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/sheets" {
+ t.Fatalf("locally signed-in / with Home = %d %q, want 303 /sheets", rec.Code, rec.Header().Get("Location"))
+ }
+ // Standalone keeps its landing page for a stranger.
+ if rec := page(t, New(Config{}), nil, "/"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "<h1>Sheets</h1>") {
+ t.Fatalf("standalone signed-out / = %d, want the landing page", rec.Code)
+ }
+}
+
+// §9.2's last row: somebody already signed in never sees the form. With
+// Home, connect's /signin does it (home/connect 01166fc); standalone,
+// Sheets' own handler does, to a safe return_to or the list, and never
+// to a sign-in page (which would loop).
+func TestSigninWhenAlreadySignedIn(t *testing.T) {
+ app, cookies := signIn(t)
+ for query, want := range map[string]string{
+ "": "/sheets",
+ "?return_to=%2Fsheets%2F4": "/sheets/4",
+ "?return_to=%2Fsignin": "/sheets",
+ "?return_to=%2F%2Fevil.test": "/sheets",
+ } {
+ rec := page(t, app, cookies, "/signin"+query)
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != want {
+ t.Errorf("signed-in /signin%s = %d %q, want 303 %s", query, rec.Code, rec.Header().Get("Location"), want)
+ }
+ }
+ // The outcome pages still render: a keymail failure must be able to
+ // offer the email path to somebody holding a session.
+ if rec := page(t, app, cookies, "/signin?force=1&err=keymail"); rec.Code != http.StatusOK {
+ t.Errorf("?force=1&err=keymail = %d, want the form", rec.Code)
+ }
+ f := newHomeFixture(t)
+ homeSignedIn(t, f)
+ if rec := f.request(t, "GET", "/signin?return_to=%2Fsheets%2F4", nil, false); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/sheets/4" {
+ t.Errorf("Home session /signin = %d %q, want 303 /sheets/4", rec.Code, rec.Header().Get("Location"))
+ }
+}
+
+// §9.2: Sign out lands on the signed-out page, "You’re signed out", with
+// an empty corner and a way back in — standalone too, where rastrillo's
+// auth sends it to the sign-in form.
+func TestSignOutLandsOnTheSignedOutPage(t *testing.T) {
+ app, cookies := signIn(t)
+ req := as(postForm("/signout", nil), cookies)
+ req.Header.Set("Origin", testOrigin)
+ rec := do(t, app, req)
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signed-out" {
+ t.Fatalf("sign out = %d %q, want 303 /signed-out", rec.Code, rec.Header().Get("Location"))
+ }
+ if after := page(t, app, cookies, "/sheets"); after.Code == http.StatusOK {
+ t.Fatal("the session survived sign-out")
+ }
+ body := page(t, app, nil, "/signed-out").Body.String()
+ for _, want := range []string{"<h1>You’re signed out</h1>", "You’ve signed out of Sheets on this device.", `<a rst-btn="primary" href="/signin">Sign in</a>`} {
+ if !strings.Contains(body, want) {
+ t.Errorf("the signed-out page lacks %s", want)
+ }
+ }
+}
+
+// A Home user whose admission is revoked while their session is live must
+// not bounce between "/" and "/signin" forever (Astra, #39 finding 2).
+// "/" sent them to /signin because identity refused them; connect's
+// /signin saw the still-live cookie and sent them back to "/". The root
+// now goes through the same guard as every signed-in page, which ends
+// the stale session, so the redirects terminate at a fresh sign-in.
+func TestARevokedHomeUserIsNotBouncedBetweenTheRootAndSignIn(t *testing.T) {
+ f := newHomeFixture(t)
+ member := homeSignedIn(t, f)
+ f.app.admit = func(string) bool { return false }
+ if err := f.app.db.Where("member_id = ?", member.ID).Delete(&Grant{}).Error; err != nil {
+ t.Fatal(err)
+ }
+ path, seen := "/", map[string]int{}
+ for hop := 0; ; hop++ {
+ if hop == 8 {
+ t.Fatalf("still redirecting after 8 hops: %v", seen)
+ }
+ seen[path]++
+ if seen[path] > 1 {
+ t.Fatalf("redirect loop: %s visited twice (%v)", path, seen)
+ }
+ rec := f.request(t, "GET", path, nil, false)
+ loc := rec.Header().Get("Location")
+ if rec.Code != http.StatusSeeOther || loc == "" {
+ break
+ }
+ u, err := url.Parse(loc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if u.Host != "" {
+ // Off to id.* for a fresh sign-in: the loop is over.
+ if u.Host != "id.example.com" {
+ t.Fatalf("left for %s", loc)
+ }
+ break
+ }
+ path = u.RequestURI()
+ }
+ if _, ok := f.jar[f.app.homeSessions.CookieName()]; ok {
+ t.Fatal("the revoked session survived")
+ }
+}
diff --git a/internal/sheets/chrome_test.go b/internal/sheets/chrome_test.go
new file mode 100644
index 0000000..720aac6
--- /dev/null
+++ b/internal/sheets/chrome_test.go
@@ -0,0 +1,187 @@
+package sheets
+
+// The shared chrome, as Sheets renders it (estilo spec/shared-chrome,
+// §5, §6, §7, §8, §9, §10). Each test names the section it holds.
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "regexp"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+var pageTitle = regexp.MustCompile(`<title>([^<]*)</title>`)
+
+// page GETs path as a browser does, with the session cookies.
+func page(t *testing.T, app *App, cookies []*http.Cookie, path string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ req.Header.Set("Accept", "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8")
+ return do(t, app, as(req, cookies))
+}
+
+func titleOf(t *testing.T, body string) string {
+ t.Helper()
+ m := pageTitle.FindStringSubmatch(body)
+ if m == nil {
+ t.Fatal("no <title>")
+ }
+ return m[1]
+}
+
+// homeSignedIn signs the fixture's browser in THROUGH Home: a linked
+// account, then an ordinary sign-in by assertion, so the session's
+// method is Home's.
+func homeSignedIn(t *testing.T, f *homeFixture) Member {
+ t.Helper()
+ member := f.local(t, signedInAddress)
+ f.link(t, "opaque-home-subject")
+ f.jar = map[string]*http.Cookie{}
+ signin := f.request(t, "GET", "/signin?return_to=%2Fsheets", nil, false)
+ start := f.request(t, "GET", signin.Header().Get("Location"), nil, false)
+ if finish := f.exchange(t, start, "opaque-home-subject"); finish.Code != 303 {
+ t.Fatal("Home sign-in", finish.Code)
+ }
+ return member
+}
+
+// §10: every <title> is "{part} · {part} · Sheets", built in the layout,
+// and a page's {Page} is its h1. None says "Oficina Sheets", "—" or "|".
+func TestPageTitlesFollowTheSuitePattern(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Commission Q2")
+ id := strconv.FormatUint(s.ID, 10)
+ for path, want := range map[string]string{
+ "/sheets": "Sheets",
+ "/sheets/" + id: "Commission Q2 · Sheets",
+ "/sheets/" + id + "/table": "Table view · Commission Q2 · Sheets",
+ "/sheets/" + id + "/history": "Version history · Commission Q2 · Sheets",
+ "/sheets/" + id + "/comments": "Comments · Commission Q2 · Sheets",
+ "/sheets/" + id + "/share": "Share · Commission Q2 · Sheets",
+ "/sheets/import": "Import a spreadsheet · Sheets",
+ "/sheets/99999": "Sheet not available · Sheets",
+ "/no/such/page": "Page not found · Sheets",
+ } {
+ if got := titleOf(t, page(t, app, cookies, path).Body.String()); got != want {
+ t.Errorf("%s: <title> %q, want %q", path, got, want)
+ }
+ }
+ // A sub-page's h1 is its {Page}; the sheet's title is in the bar.
+ for path, h1 := range map[string]string{
+ "/sheets/" + id + "/table": "Table view",
+ "/sheets/" + id + "/history": "Version history",
+ "/sheets/" + id + "/comments": "Comments",
+ "/sheets/" + id + "/share": "Share",
+ "/sheets": "All sheets",
+ } {
+ body := page(t, app, cookies, path).Body.String()
+ if !strings.Contains(body, "<h1>"+h1+"</h1>") {
+ t.Errorf("%s: the h1 is not %q", path, h1)
+ }
+ if path != "/sheets" && !strings.Contains(body, `<span estilo-bar-context dir="auto" title="Commission Q2">Commission Q2</span>`) {
+ t.Errorf("%s: the sheet's title is not the bar's context", path)
+ }
+ }
+ for path, want := range map[string]string{
+ "/": "Sheets",
+ "/signin": "Sign in · Sheets",
+ "/signin?sent=1": "Check your email · Sheets",
+ "/signed-out": "Signed out · Sheets",
+ "/no/such/page": "Page not found · Sheets",
+ "/signin?err=bad": "Sign in · Sheets",
+ } {
+ if got := titleOf(t, page(t, app, nil, path).Body.String()); got != want {
+ t.Errorf("%s signed out: <title> %q, want %q", path, got, want)
+ }
+ }
+}
+
+// §5.1 / §8.2: on every signed-in page the account menu is the last
+// thing in the bar, and "Sign out" appears exactly once in the page —
+// it used to be the account list's fourth flat row.
+func TestEverySignedInPageHasOneSignOutInTheSameCorner(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+ id := strconv.FormatUint(s.ID, 10)
+ for _, path := range []string{"/sheets", "/sheets/" + id, "/sheets/" + id + "/table", "/sheets/" + id + "/share", "/sheets/" + id + "/history", "/sheets/" + id + "/comments", "/sheets/import", "/no/such/page", "/sheets/99999"} {
+ body := page(t, app, cookies, path).Body.String()
+ if n := strings.Count(body, ">Sign out<"); n != 1 {
+ t.Errorf("%s: %d Sign out, want exactly one", path, n)
+ }
+ end := body[strings.Index(body, "<div estilo-bar-end>"):strings.Index(body, "</header>")]
+ tail := end[strings.LastIndex(end, "</details>"):]
+ if !strings.Contains(end, "<details estilo-account") || strings.Count(tail, "<") != 2 { // </details> </div>
+ t.Errorf("%s: the account is not the last thing in the bar", path)
+ }
+ if strings.Contains(body, "rst-shell-") {
+ t.Errorf("%s: the rastrillo topbar is still rendered", path)
+ }
+ }
+}
+
+// §5.1 / §8.4: signed out, the corner is "Sign in" carrying the page to
+// come back to; on the sign-in pages themselves it is empty; on the
+// public link page the brand is plain text and Sign in carries no path
+// (the path is a capability).
+func TestTheSignedOutCorner(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+ token, err := app.createLink(s.ID, SheetViewer, signedInMember(t, app))
+ if err != nil {
+ t.Fatal(err)
+ }
+ body := page(t, app, nil, "/no/such/page?x=1").Body.String()
+ if !strings.Contains(body, `<a rst-btn href="/signin?return_to=%2Fno%2Fsuch%2Fpage%3Fx%3D1" estilo-bar-signin>Sign in</a>`) {
+ t.Error("a signed-out page's corner is not Sign in with its return_to")
+ }
+ for _, path := range []string{"/signin", "/signin?sent=1", "/signed-out"} {
+ body := page(t, app, nil, path).Body.String()
+ if strings.Contains(body, "estilo-bar-signin") || strings.Contains(body, "estilo-account") {
+ t.Errorf("%s: the corner is not empty on a sign-in page", path)
+ }
+ }
+ link := page(t, app, nil, "/l/"+token).Body.String()
+ if !strings.Contains(link, "<span estilo-bar-brand>") || strings.Contains(link, "<a estilo-bar-brand") {
+ t.Error("the public link page's brand is a link a stranger would be bounced through")
+ }
+ if !strings.Contains(link, `<a rst-btn href="/signin" estilo-bar-signin>Sign in</a>`) || strings.Contains(link, "return_to=%2Fl%2F") {
+ t.Error("the link page's Sign in is missing, or copies the capability path into a URL")
+ }
+}
+
+// §6: the switcher shows only when Home names a hub directory, and then
+// holds "All apps" alone (Paul's §13 answer 2) — no tiles yet.
+func TestTheSwitcherIsAllAppsOnly(t *testing.T) {
+ app, cookies := signIn(t)
+ if body := page(t, app, cookies, "/sheets").Body.String(); strings.Contains(body, "estilo-switcher") {
+ t.Error("a standalone instance shows a switcher; there is no directory to switch through")
+ }
+ f := newHomeFixtureWith(t, "https://oficina.example.com")
+ f.local(t, signedInAddress)
+ body := f.request(t, "GET", "/sheets", nil, false).Body.String()
+ for _, want := range []string{`<details estilo-switcher name="estilo-menus">`, `aria-label="Oficina apps"`, `<a estilo-menu-item href="https://oficina.example.com/"><span estilo-menu-label>All apps</span></a>`} {
+ if !strings.Contains(body, want) {
+ t.Errorf("the switcher lacks %s", want)
+ }
+ }
+ if strings.Contains(body, "estilo-switcher-app") {
+ t.Error("the switcher renders app tiles before Home's /apps/<slug> hop is ready")
+ }
+}
+
+// §5.1 / §5.3: the grid is the one page whose shell fills the viewport;
+// every other page's shell is plain, and every page has the bar.
+func TestTheGridFillsTheViewport(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+ grid := page(t, app, cookies, "/sheets/"+strconv.FormatUint(s.ID, 10)).Body.String()
+ if !strings.Contains(grid, `<div estilo-shell="fill">`) || !strings.Contains(grid, `<header estilo-bar>`) {
+ t.Error("the grid's shell does not fill the viewport, or its bar is sticky in a page that never scrolls")
+ }
+ list := page(t, app, cookies, "/sheets").Body.String()
+ if !strings.Contains(list, "<div estilo-shell>") || !strings.Contains(list, `<header estilo-bar="sticky">`) {
+ t.Error("an ordinary page's shell or bar is wrong")
+ }
+}
diff --git a/internal/sheets/errors.go b/internal/sheets/errors.go
new file mode 100644
index 0000000..de31d96
--- /dev/null
+++ b/internal/sheets/errors.go
@@ -0,0 +1,271 @@
+package sheets
+
+// HTML error pages (spec §3). Before this every failure a browser could
+// reach was http.Error's plain text in the browser's monospace font, with
+// no chrome, no <title> and no way back: "Not found." for a stale link or
+// a revoked share, and chi's own "404 page not found" for an unknown
+// route.
+//
+// Which answers are pages is decided by the request, not the route: a
+// page is rendered only when Accept names text/html, as every browser
+// navigation and form post does. a.notFound is the case the spec names
+// (§3.3): one helper reached from the grid's page and from its /api
+// fetches through sheetFromURL, so the split has to happen inside it.
+// The grid's fetches, the live stream and the native apps never ask for
+// HTML, so they keep the plain answers they have always had.
+//
+// A missing sheet and one that is not shared with you are the same 404
+// (AGENTS.md): nothing here distinguishes them, so nothing is enumerable.
+
+import (
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "runtime/debug"
+ "strings"
+
+ "amadan.net/oficina/estilo.git/chrome"
+ "github.com/carlosframework/rastrillo"
+ "github.com/go-chi/chi/v5"
+)
+
+var errNoTemplate = errors.New("sheets: no such page template")
+
+// errorCopy is one row of the spec's §3.2 table.
+type errorCopy struct {
+ title, heading, body string
+}
+
+// errorKind says which row a failure is.
+type errorKind int
+
+const (
+ // kindRoute: nothing lives at this address.
+ kindRoute errorKind = iota
+ // kindSheet: the sheet (or link) is missing, or not shared with you.
+ kindSheet
+ // kindStatus: the page for the status itself (400, 403, 413, 5xx).
+ kindStatus
+)
+
+func copyFor(status int, kind errorKind) errorCopy {
+ switch {
+ case status == http.StatusNotFound && kind == kindSheet:
+ return errorCopy{"Sheet not available", "This sheet isn’t available", "It may have been deleted, or it hasn’t been shared with you."}
+ case status == http.StatusNotFound:
+ return errorCopy{"Page not found", "We can’t find that page", "The link may be out of date, or the page may have moved."}
+ case status == http.StatusForbidden:
+ return errorCopy{"No access", "You don’t have access to this page", "Ask whoever runs this team if you think you should."}
+ case status == http.StatusRequestEntityTooLarge:
+ return errorCopy{"Too long", "That’s too long", "Go back, make it shorter and try again."}
+ case status == http.StatusBadRequest:
+ return errorCopy{"That didn’t work", "That didn’t work", "Go back and try again."}
+ case status == http.StatusServiceUnavailable:
+ return errorCopy{appName + " is unavailable", appName + " is unavailable right now", "Try again in a few minutes."}
+ }
+ return errorCopy{"Something went wrong", "Something went wrong", "Try again in a moment. If it keeps happening, quote the reference below."}
+}
+
+type errorPageView struct {
+ Error chrome.ErrorPage
+}
+
+// wantsHTML reports whether the request asked for a page.
+func wantsHTML(r *http.Request) bool {
+ return strings.Contains(r.Header.Get("Accept"), "text/html")
+}
+
+// notFound answers 404 with nothing that could name a sheet: the "sheet
+// not available" page for a browser, and the plain "Not found." it has
+// always been for the grid's fetches and the native apps.
+func (a *App) notFound(w http.ResponseWriter, r *http.Request) {
+ a.answer(w, r, http.StatusNotFound, kindSheet, "Not found.")
+}
+
+// routeNotFound is the router's NotFound: an unknown address.
+func (a *App) routeNotFound(w http.ResponseWriter, r *http.Request) {
+ a.answer(w, r, http.StatusNotFound, kindRoute, "Not found.")
+}
+
+// forbidden answers a request that needed an actor and had none. Behind
+// requireSession it is unreachable; it is what a handler says if that
+// guard is ever not in front of it.
+func (a *App) forbidden(w http.ResponseWriter, r *http.Request) {
+ a.answer(w, r, http.StatusForbidden, kindStatus, "Sign in first.")
+}
+
+// badRequest answers a form that could not be read, or asked for
+// something the route cannot do. text is the plain answer a script or a
+// native client has always read.
+func (a *App) badRequest(w http.ResponseWriter, r *http.Request, status int, text string) {
+ a.answer(w, r, status, kindStatus, text)
+}
+
+// fail answers 500.
+//
+// The error is deliberately dropped rather than shown or logged: it can
+// carry a sealed title's plaintext, a cell's contents, or a SQL
+// statement quoting either, and `carlos logs` is platform-held storage
+// we cannot grep or redact (AGENTS.md / Mechanical rules). The page
+// quotes a reference, and the log line carries that reference and the
+// route pattern, never the path: a /l/ path is a capability.
+func (a *App) fail(w http.ResponseWriter, r *http.Request, _ error) {
+ if !wantsHTML(r) {
+ http.Error(w, "Something went wrong.", http.StatusInternalServerError)
+ return
+ }
+ ref := rastrillo.NewRef()
+ slog.Error("sheets: error page", "ref", ref, "route", routePattern(r))
+ a.errorPage(w, r, http.StatusInternalServerError, kindStatus, ref)
+}
+
+func (a *App) answer(w http.ResponseWriter, r *http.Request, status int, kind errorKind, text string) {
+ if !wantsHTML(r) {
+ http.Error(w, text, status)
+ return
+ }
+ a.errorPage(w, r, status, kind, "")
+}
+
+// routePattern is the matched route, "/sheets/{sheetID}" rather than the
+// path it matched: safe to log whatever the path held.
+func routePattern(r *http.Request) string {
+ if rc := chi.RouteContext(r.Context()); rc != nil && rc.RoutePattern() != "" {
+ return rc.RoutePattern()
+ }
+ return "(unrouted)"
+}
+
+// errorPage renders the page for status inside the normal layout, with
+// whatever chrome the request's session gives it. ref is the reference a
+// 500 quotes, empty otherwise.
+func (a *App) errorPage(w http.ResponseWriter, r *http.Request, status int, kind errorKind, ref string) {
+ c := copyFor(status, kind)
+ page := chrome.ErrorPage{
+ Status: status,
+ Title: c.heading,
+ Body: c.body,
+ Home: chrome.Link{Href: "/", Label: "Go to " + appName},
+ }
+ // Try again is the same URL, and only for a GET: re-sending a POST
+ // from an error page is the one thing it must never do.
+ if status >= 500 && r.Method == http.MethodGet {
+ page.Home = chrome.Link{Href: r.URL.RequestURI(), Label: "Try again"}
+ }
+ if ref != "" {
+ page.Ref, page.RefLabel = ref, "Reference:"
+ }
+ if back := a.sameOriginReferer(r); back != "" && status < 500 {
+ page.Back = &chrome.Link{Href: back, Label: "Back"}
+ }
+ t, ok := pages["error"]
+ var buf strings.Builder
+ if ok {
+ err := t.ExecuteTemplate(&buf, "layout", PageData{Frame: a.frameFor(r, titled(c.title)), Data: errorPageView{Error: page}})
+ ok = err == nil
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ if !ok {
+ // The page could not render: say so in the plainest way left.
+ http.Error(w, "Something went wrong.", status)
+ return
+ }
+ pageHeaders(w)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ _, _ = w.Write([]byte(buf.String()))
+}
+
+// sameOriginReferer is the page a person came from, when it is this app
+// and not this page: rastrillo's rule for the error page's Back link. An
+// unchecked Referer is an open redirect with better manners.
+func (a *App) sameOriginReferer(r *http.Request) string {
+ raw := r.Header.Get("Referer")
+ if raw == "" || a.origin == "" {
+ return ""
+ }
+ u, err := url.Parse(raw)
+ if err != nil || u.User != nil || u.Scheme+"://"+u.Host != a.origin {
+ return ""
+ }
+ back := u.EscapedPath()
+ if u.RawQuery != "" {
+ back += "?" + u.RawQuery
+ }
+ if !strings.HasPrefix(back, "/") || strings.HasPrefix(back, "//") || back == r.URL.RequestURI() || strings.HasPrefix(back, "/l/") {
+ return ""
+ }
+ return back
+}
+
+// methodNotAllowed is the router's MethodNotAllowed. A GET or HEAD to a
+// path that only takes a POST (somebody opening /signout from history) is
+// the same 404 page as an unknown route: to a person there is no page
+// there. Any other method gets 405 in plain text, with the Allow header
+// set here — chi's default sets it, a custom handler does not.
+func (a *App) methodNotAllowed(routes chi.Routes) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var allowed []string
+ for _, m := range []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} {
+ if routes.Match(chi.NewRouteContext(), m, r.URL.Path) {
+ allowed = append(allowed, m)
+ }
+ }
+ get := false
+ for _, m := range allowed {
+ get = get || m == http.MethodGet
+ }
+ if (r.Method == http.MethodGet || r.Method == http.MethodHead) && !get {
+ a.routeNotFound(w, r)
+ return
+ }
+ w.Header().Set("Allow", strings.Join(allowed, ", "))
+ http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+ }
+}
+
+// recoverToErrorPage turns a panic in a handler into the 500 page with a
+// reference, logged with the stack under the same reference.
+//
+// It is mounted INSIDE the signed-in group, after the session guard, so
+// the page carries the viewer's own chrome: mounted outside, every 500
+// would look signed out, which is Calendar's bug (spec §3.3). Panics
+// outside it reach rastrillo.Serve's own recoverer through
+// App.ErrorPage.
+func (a *App) recoverToErrorPage(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ defer func() {
+ v := recover()
+ if v == nil {
+ return
+ }
+ if v == http.ErrAbortHandler {
+ panic(v)
+ }
+ ref := rastrillo.NewRef()
+ // The panic value and stack name code, not content: a panic
+ // raised with a cell's text in it would be a bug in its own
+ // right, and the value is logged as the fmt of its type only.
+ slog.Error("sheets: panic", "ref", ref, "route", routePattern(r), "panic", fmt.Sprintf("%T", v), "stack", string(debug.Stack()))
+ if !wantsHTML(r) {
+ http.Error(w, "Something went wrong.", http.StatusInternalServerError)
+ return
+ }
+ a.errorPage(w, r, http.StatusInternalServerError, kindStatus, ref)
+ }()
+ next.ServeHTTP(w, r)
+ })
+}
+
+// ErrorPage is rastrillo.Options.ErrorPage: the page rastrillo.Serve's
+// own panic recovery renders, for a panic outside the signed-in group.
+// It reads the session optionally like every other error page.
+func (a *App) ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) {
+ if !wantsHTML(r) {
+ http.Error(w, "Something went wrong.", status)
+ return
+ }
+ a.errorPage(w, r, status, kindStatus, ref)
+}
diff --git a/internal/sheets/errors_test.go b/internal/sheets/errors_test.go
new file mode 100644
index 0000000..7574922
--- /dev/null
+++ b/internal/sheets/errors_test.go
@@ -0,0 +1,249 @@
+package sheets
+
+import (
+ "fmt"
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+// Every error a browser can reach is the error page, inside the normal
+// layout and bar, showing the viewer's own chrome (spec §3). The table is
+// {unknown route, a GET of a POST-only path, a missing sheet, a missing
+// link, a bad parameter, a panic} × {signed in, signed out}.
+func TestHTMLErrorsRenderTheErrorPage(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+ id := strconv.FormatUint(s.ID, 10)
+
+ type result struct {
+ code int
+ ct string
+ body string
+ }
+ get := func(path string) func([]*http.Cookie) result {
+ return func(c []*http.Cookie) result {
+ rec := page(t, app, c, path)
+ return result{rec.Code, rec.Header().Get("Content-Type"), rec.Body.String()}
+ }
+ }
+ tooLong := func(c []*http.Cookie) result {
+ req := postForm("/sheets/"+id+"/table", map[string][]string{"r": {"0"}, "c": {"0"}, "raw": {strings.Repeat("x", maxRawBytes+1)}})
+ req.Header.Set("Accept", "text/html")
+ rec := do(t, app, as(req, c))
+ return result{rec.Code, rec.Header().Get("Content-Type"), rec.Body.String()}
+ }
+ panics := func(c []*http.Cookie) result {
+ h := app.recoverToErrorPage(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("boom") }))
+ req := httptest.NewRequest(http.MethodGet, "/sheets/"+id, nil)
+ req.Header.Set("Accept", "text/html")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, as(req, c))
+ return result{rec.Code, rec.Header().Get("Content-Type"), rec.Body.String()}
+ }
+
+ cases := []struct {
+ name string
+ signedIn bool
+ run func([]*http.Cookie) result
+ status int
+ title string
+ }{
+ {"unknown route", true, get("/no/such/page"), 404, "Page not found · Sheets"},
+ {"unknown route", false, get("/no/such/page"), 404, "Page not found · Sheets"},
+ {"GET of a POST-only path", true, get("/signout"), 404, "Page not found · Sheets"},
+ {"GET of a POST-only path", false, get("/appearance"), 404, "Page not found · Sheets"},
+ {"missing sheet", true, get("/sheets/99999"), 404, "Sheet not available · Sheets"},
+ {"not a sheet id", true, get("/sheets/nope/table"), 404, "Sheet not available · Sheets"},
+ {"missing link", false, get("/l/doesnotexist"), 404, "Sheet not available · Sheets"},
+ {"missing link", true, get("/l/doesnotexist"), 404, "Sheet not available · Sheets"},
+ {"bad parameter", true, tooLong, 413, "Too long · Sheets"},
+ {"panic", true, panics, 500, "Something went wrong · Sheets"},
+ {"panic", false, panics, 500, "Something went wrong · Sheets"},
+ }
+ for _, c := range cases {
+ who, jar := "signed out", []*http.Cookie(nil)
+ if c.signedIn {
+ who, jar = "signed in", cookies
+ }
+ t.Run(c.name+", "+who, func(t *testing.T) {
+ got := c.run(jar)
+ if got.code != c.status {
+ t.Fatalf("status %d, want %d", got.code, c.status)
+ }
+ if !strings.HasPrefix(got.ct, "text/html") {
+ t.Fatalf("Content-Type %q, want text/html", got.ct)
+ }
+ if title := titleOf(t, got.body); title != c.title {
+ t.Fatalf("<title> %q, want %q", title, c.title)
+ }
+ if !strings.Contains(got.body, "<header estilo-bar") || !strings.Contains(got.body, "<div rst-error>") {
+ t.Fatal("the error page is not inside the layout and bar")
+ }
+ if account := strings.Contains(got.body, "<details estilo-account"); account != c.signedIn {
+ t.Fatalf("account menu present = %v, want %v", account, c.signedIn)
+ }
+ if c.status == 500 && !regexp.MustCompile(`rst-error-ref dir="auto">Reference: [a-z0-9]{6}<`).MatchString(got.body) {
+ t.Fatal("a 500 page carries no reference")
+ }
+ })
+ }
+
+ // Non-HTML stays non-HTML: a.notFound is shared by the grid's page
+ // and its /api fetches (spec §3.3), so the split is by Accept. The
+ // grid's fetch and a native client never ask for a page and never
+ // get one.
+ for _, p := range []string{"/no/such/page", "/sheets/99999", fmt.Sprintf("/api/sheets/99999/tabs/%d/cells", s.TabID)} {
+ for _, accept := range []string{"", "application/json", "*/*"} {
+ req := httptest.NewRequest(http.MethodGet, p, nil)
+ if accept != "" {
+ req.Header.Set("Accept", accept)
+ }
+ rec := do(t, app, as(req, cookies))
+ if rec.Code != http.StatusNotFound || strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") || strings.Contains(rec.Body.String(), "<html") {
+ t.Errorf("%s Accept %q: %d %q, want a plain 404", p, accept, rec.Code, rec.Header().Get("Content-Type"))
+ }
+ }
+ }
+ // Any other method to a known path is 405 in plain text, with Allow.
+ req := httptest.NewRequest(http.MethodDelete, "/signout", nil)
+ rec := do(t, app, req)
+ if rec.Code != http.StatusMethodNotAllowed || rec.Header().Get("Allow") != "POST" {
+ t.Errorf("DELETE /signout: %d Allow=%q", rec.Code, rec.Header().Get("Allow"))
+ }
+}
+
+// The error page's Back link is offered only for a same-origin Referer
+// that is not this page (rastrillo's rule; otherwise it is an open
+// redirect with better manners), and never for a capability path.
+func TestErrorPageBackLinkIsSameOriginOnly(t *testing.T) {
+ app, _ := appWithOrigin(t, testOrigin)
+ for referer, want := range map[string]string{
+ testOrigin + "/sheets?page=2": `<a rst-btn href="/sheets?page=2">Back</a>`,
+ "https://evil.example/sheets": "",
+ testOrigin + "/no/such/page": "",
+ "//evil.example/x": "",
+ testOrigin + "/l/secret-token": "",
+ "https://u@sheets.example.com/": "",
+ } {
+ req := httptest.NewRequest(http.MethodGet, "/no/such/page", nil)
+ req.Header.Set("Accept", "text/html")
+ req.Header.Set("Referer", referer)
+ body := do(t, app, req).Body.String()
+ has := strings.Contains(body, `>Back</a>`)
+ if (want == "") == has || (want != "" && !strings.Contains(body, want)) {
+ t.Errorf("Referer %q: Back link present = %v, want %q", referer, has, want)
+ }
+ }
+}
+
+// Every http.Error left in the package is classified: a plain answer on
+// purpose, for a fetch, a stream or a native client, or a middleware
+// refusal no navigation meets. A new unclassified site fails here — a
+// page handler should call a.notFound, a.fail, a.forbidden or
+// a.badRequest, which answer a browser with the page.
+func TestEveryPlainTextErrorIsClassified(t *testing.T) {
+ classified := map[string]struct {
+ n int
+ why string
+ }{
+ "errors.go:App.fail": {1, "the non-HTML half of the negotiated answer"},
+ "errors.go:App.answer": {1, "the non-HTML half of the negotiated answer"},
+ "errors.go:App.errorPage": {1, "the error page itself could not render"},
+ "errors.go:App.methodNotAllowed": {1, "405 for a non-GET method, which no navigation sends"},
+ "errors.go:App.recoverToErrorPage": {1, "the non-HTML half of a recovered panic"},
+ "errors.go:App.ErrorPage": {1, "the non-HTML half of rastrillo's recovered panic"},
+ "handlers_cell_batch.go:App.handleCellBatchPost": {7, "grid JSON API"},
+ "handlers_format.go:App.handleFormatPost": {6, "grid JSON API"},
+ "handlers_grid.go:App.handleCellsGet": {1, "grid JSON API"},
+ "handlers_grid.go:App.handleCellPost": {7, "grid JSON API"},
+ "handlers_size.go:App.handleSizePost": {7, "grid JSON API"},
+ "handlers_rename.go:App.handleSheetRename": {1, "native only: a browser's blank title redirects back"},
+ "handlers_view.go:App.handleViewSave": {1, "native only: a browser's bad spec redirects back"},
+ "home.go:App.homeRequire": {2, "shared session guard for pages and fetches; a page GET redirects to /signin (R3)"},
+ "home.go:App.homeLocalComplete": {2, "Home link completion refusals (R3: moves with home/connect)"},
+ "home.go:App.rejectDisabledHome": {1, "shared session guard for pages and fetches (R3)"},
+ "jobpages.go:App.renderJobFragment": {2, "the job page's polled fragment, not a page"},
+ }
+ got := httpErrorSites(t)
+ var problems []string
+ for site, n := range got {
+ c, ok := classified[site]
+ switch {
+ case !ok:
+ problems = append(problems, fmt.Sprintf("%s: %d unclassified plain-text error(s)", site, n))
+ case c.n != n:
+ problems = append(problems, fmt.Sprintf("%s: %d plain-text error(s), classified %d (%s)", site, n, c.n, c.why))
+ }
+ }
+ for site := range classified {
+ if _, ok := got[site]; !ok {
+ problems = append(problems, site+": classified but no longer present — drop it from the list")
+ }
+ }
+ sort.Strings(problems)
+ for _, p := range problems {
+ t.Error(p)
+ }
+ if len(got) == 0 {
+ t.Fatal("found no http.Error sites at all; the walk is looking at nothing")
+ }
+}
+
+// httpErrorSites counts http.Error and http.NotFound calls per
+// file:function in this package's non-test sources.
+func httpErrorSites(t *testing.T) map[string]int {
+ t.Helper()
+ fset := token.NewFileSet()
+ files, err := filepath.Glob("*.go")
+ if err != nil {
+ t.Fatal(err)
+ }
+ sites := map[string]int{}
+ for _, f := range files {
+ if strings.HasSuffix(f, "_test.go") {
+ continue
+ }
+ file, err := parser.ParseFile(fset, f, nil, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, d := range file.Decls {
+ fn, ok := d.(*ast.FuncDecl)
+ if !ok {
+ continue
+ }
+ name := fn.Name.Name
+ if fn.Recv != nil {
+ typ := fn.Recv.List[0].Type
+ if s, ok := typ.(*ast.StarExpr); ok {
+ typ = s.X
+ }
+ if id, ok := typ.(*ast.Ident); ok {
+ name = id.Name + "." + name
+ }
+ }
+ ast.Inspect(fn, func(n ast.Node) bool {
+ call, ok := n.(*ast.CallExpr)
+ if !ok {
+ return true
+ }
+ if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
+ if x, ok := sel.X.(*ast.Ident); ok && x.Name == "http" && (sel.Sel.Name == "Error" || sel.Sel.Name == "NotFound") {
+ sites[f+":"+name]++
+ }
+ }
+ return true
+ })
+ }
+ }
+ return sites
+}
diff --git a/internal/sheets/format/numeric.go b/internal/sheets/format/numeric.go
index 5248df4..3da1cbc 100644
--- a/internal/sheets/format/numeric.go
+++ b/internal/sheets/format/numeric.go
@@ -42,13 +42,26 @@ func tokenizeNumeric(section string) ([]numTok, bool) {
}
// generalNumber renders val (>= 0) as Excel's General format would for
-// any ordinary-magnitude number: the shortest decimal representation
-// that round-trips to the same float64, no grouping, no forced decimal
-// places. Excel's actual General format switches to scientific notation
-// and caps significant digits at very large or very small magnitudes;
-// this package does not replicate that — see the package doc.
+// any ordinary-magnitude number: at most 15 significant digits, no
+// grouping, no forced decimal places, trailing zeros dropped.
+//
+// Fifteen, not the shortest round-trip representation this used to
+// print: a double holds 15 decimal digits for certain and the 16th and
+// 17th are binary noise, so =C4*0.1 with C4 = 920.5 showed
+// 92.05000000000001 (TestGeneralShowsAtMostFifteenSignificantDigits).
+// Excel's General stops at 15 too. Only the display rounds; the stored
+// value and everything computed from it stay the exact double (AGENTS.md:
+// cell values are IEEE doubles end to end).
+//
+// Excel's actual General format also switches to scientific notation at
+// very large or very small magnitudes; this package does not replicate
+// that — see the package doc.
func generalNumber(val float64) string {
- return strconv.FormatFloat(val, 'f', -1, 64)
+ rounded, err := strconv.ParseFloat(strconv.FormatFloat(val, 'g', 15, 64), 64)
+ if err != nil {
+ return strconv.FormatFloat(val, 'f', -1, 64)
+ }
+ return strconv.FormatFloat(rounded, 'f', -1, 64)
}
func isASCIILetter(c byte) bool {
diff --git a/internal/sheets/format/rounding_test.go b/internal/sheets/format/rounding_test.go
index df4d4aa..a8dc5bd 100644
--- a/internal/sheets/format/rounding_test.go
+++ b/internal/sheets/format/rounding_test.go
@@ -2,6 +2,7 @@ package format
import (
"math"
+ "strconv"
"testing"
"time"
@@ -175,3 +176,40 @@ func TestDisplayDates(t *testing.T) {
})
}
}
+
+// Automatic ("General") shows at most 15 significant digits, the most a
+// double holds for certain and what Excel's own General stops at. It
+// printed the shortest round-trip representation instead, so =C4*0.1
+// with C4 = 920.5 showed 92.05000000000001 in the table view and
+// "92.050000000…" clipped in the grid (audit, sheets-16/45/64). The
+// stored value is the exact double either way; only the display rounds.
+func TestGeneralShowsAtMostFifteenSignificantDigits(t *testing.T) {
+ // Variables, not constant expressions: Go folds 920.5 * 0.1 exactly
+ // at compile time, and the artefact only exists in float64
+ // arithmetic, which is what the evaluator does.
+ tenth, a, b, c1, c2 := 0.1, 920.5, 3228.33, 0.2, 3.0
+ if s := strconv.FormatFloat(a*tenth, 'f', -1, 64); s != "92.05000000000001" {
+ t.Fatalf("the float artefact this test is about did not happen (%s)", s)
+ }
+ for _, c := range []struct {
+ v float64
+ want string
+ }{
+ {a * tenth, "92.05"},
+ {b * tenth, "322.833"},
+ {tenth + c1, "0.3"},
+ {1 / c2, "0.333333333333333"},
+ {1234.5678, "1234.5678"},
+ {-a * tenth, "-92.05"},
+ {100, "100"},
+ {1e21, "1000000000000000000000"},
+ {0.000001, "0.000001"},
+ } {
+ if got := Display(numValue(c.v), "General"); got != c.want {
+ t.Errorf("Display(%v, General) = %q, want %q", c.v, got, c.want)
+ }
+ if got := Display(numValue(c.v), ""); got != c.want {
+ t.Errorf("Display(%v, Automatic) = %q, want %q", c.v, got, c.want)
+ }
+ }
+}
diff --git a/internal/sheets/handlers_comments.go b/internal/sheets/handlers_comments.go
index bffe2ba..2ce457c 100644
--- a/internal/sheets/handlers_comments.go
+++ b/internal/sheets/handlers_comments.go
@@ -12,6 +12,7 @@ type commentsView struct {
Threads []CommentThread
MayComment bool
Notice string
+ NoticeTone string `json:"-"`
}
func commentNotice(code string) string {
@@ -56,10 +57,11 @@ func (a *App) handleComments(w http.ResponseWriter, r *http.Request) {
a.fail(w, r, err)
return
}
- a.render(w, r, "comments", "Comments — "+s.Title, http.StatusOK, commentsView{
+ a.render(w, r, "comments", onSheet(s, "Comments"), http.StatusOK, commentsView{
Sheet: s, Threads: threads,
MayComment: a.mayComment(s.ID, viewer),
Notice: commentNotice(r.URL.Query().Get("notice")),
+ NoticeTone: noticeTone(r.URL.Query().Get("notice")),
})
}
diff --git a/internal/sheets/handlers_history.go b/internal/sheets/handlers_history.go
index 9788f98..24d4760 100644
--- a/internal/sheets/handlers_history.go
+++ b/internal/sheets/handlers_history.go
@@ -21,10 +21,11 @@ type historyEntry struct {
}
type historyView struct {
- Sheet SheetSummary
- Entries []historyEntry
- MayWrite bool
- Notice string
+ Sheet SheetSummary
+ Entries []historyEntry
+ MayWrite bool
+ Notice string
+ NoticeTone string `json:"-"`
}
// handleHistory shows what has been typed into a sheet.
@@ -78,10 +79,11 @@ func (a *App) handleHistory(w http.ResponseWriter, r *http.Request) {
})
}
- a.render(w, r, "history", "History — "+s.Title, http.StatusOK, historyView{
+ a.render(w, r, "history", onSheet(s, "Version history"), http.StatusOK, historyView{
Sheet: s, Entries: entries,
- MayWrite: a.mayWrite(s.ID, viewer),
- Notice: historyNotice(r.URL.Query().Get("notice")),
+ MayWrite: a.mayWrite(s.ID, viewer),
+ Notice: historyNotice(r.URL.Query().Get("notice")),
+ NoticeTone: noticeTone(r.URL.Query().Get("notice")),
})
}
diff --git a/internal/sheets/handlers_import.go b/internal/sheets/handlers_import.go
index 0a5a87b..cf36210 100644
--- a/internal/sheets/handlers_import.go
+++ b/internal/sheets/handlers_import.go
@@ -83,7 +83,7 @@ const (
func (a *App) handleSheetImport(w http.ResponseWriter, r *http.Request) {
actor, ok := a.actor(r)
if !ok {
- http.Error(w, "Sign in first.", http.StatusForbidden)
+ a.forbidden(w, r)
return
}
// jobs keys ownership on the session Subject, and jobs.Handlers
@@ -94,7 +94,7 @@ func (a *App) handleSheetImport(w http.ResponseWriter, r *http.Request) {
// broken one.
sess, ok := sessions.Current(r)
if !ok {
- http.Error(w, "Sign in first.", http.StatusForbidden)
+ a.forbidden(w, r)
return
}
diff --git a/internal/sheets/handlers_import_page.go b/internal/sheets/handlers_import_page.go
index a5e78d9..8ef317d 100644
--- a/internal/sheets/handlers_import_page.go
+++ b/internal/sheets/handlers_import_page.go
@@ -39,7 +39,7 @@ func (a *App) handleImportPage(w http.ResponseWriter, r *http.Request) {
// actor check the sibling handlers make, and it is what makes the
// handler safe to call from a test that does not mount the group.
if _, ok := a.actor(r); !ok {
- http.Error(w, "Sign in first.", http.StatusForbidden)
+ a.forbidden(w, r)
return
}
a.renderImportPage(w, r, http.StatusOK, "")
@@ -55,6 +55,6 @@ func (a *App) handleImportPage(w http.ResponseWriter, r *http.Request) {
// construction. That reasoning arrived here from renderSheetList along
// with the form itself.
func (a *App) renderImportPage(w http.ResponseWriter, r *http.Request, status int, refusal string) {
- a.render(w, r, "import", "Import a spreadsheet — Oficina Sheets", status,
+ a.render(w, r, "import", titled("Import a spreadsheet"), status,
importPageView{Error: refusal})
}
diff --git a/internal/sheets/handlers_link.go b/internal/sheets/handlers_link.go
index 3f512ca..e7a35bf 100644
--- a/internal/sheets/handlers_link.go
+++ b/internal/sheets/handlers_link.go
@@ -95,7 +95,9 @@ func (a *App) handleLinkView(w http.ResponseWriter, r *http.Request) {
}
view.Rows = a.tableRows(h, (page-1)*tableRowsPerPage, -1, -1)
- a.render(w, r, "link", s.Title, http.StatusOK, view)
+ f := onSheet(s, "")
+ f.public = true
+ a.render(w, r, "link", f, http.StatusOK, view)
}
// summarizeByID reads a sheet without a grant check.
diff --git a/internal/sheets/handlers_rename.go b/internal/sheets/handlers_rename.go
index 9c8df4f..435bbbd 100644
--- a/internal/sheets/handlers_rename.go
+++ b/internal/sheets/handlers_rename.go
@@ -78,7 +78,7 @@ func (a *App) handleSheetRename(w http.ResponseWriter, r *http.Request) {
// this error and fall through to the QUERY, which is how
// ?title=x on an oversized body silently became the new name —
// caught by review, not by the suite.
- http.Error(w, "Malformed request.", http.StatusBadRequest)
+ a.badRequest(w, r, http.StatusBadRequest, "Malformed request.")
return
}
// PostForm, never Form: the title comes from the body a person
diff --git a/internal/sheets/handlers_share.go b/internal/sheets/handlers_share.go
index b564b87..f59a0fb 100644
--- a/internal/sheets/handlers_share.go
+++ b/internal/sheets/handlers_share.go
@@ -15,6 +15,9 @@ type shareView struct {
LinkRoles []SheetRole
Links []linkRow
Notice string
+ // NoticeTone is the callout's rastrillo tone (shared chrome spec §4).
+ // Not sent to the native apps, which read the notice code instead.
+ NoticeTone string `json:"-"`
// NewLink is a full URL, shown once. It is not stored anywhere, so
// a reload of this screen cannot show it again — which is the
// direct consequence of storing only the hash, and the screen says
@@ -86,10 +89,19 @@ func (a *App) renderShare(w http.ResponseWriter, r *http.Request, s SheetSummary
if notice == noticeRevokedTeam {
view.Notice = a.revokedTeamNotice(r, s.ID)
}
+ view.NoticeTone = noticeTone(notice)
if newLink != "" {
view.NewLink = a.linkURL(newLink)
+ // The new link's own callout says "copy it now" with the link
+ // in it; a second callout above it saying the same thing is the
+ // duplicate the audit found (§4: keep one, a warning).
+ if notice == "link" {
+ view.Notice = ""
+ }
}
- a.render(w, r, "share", "Sharing "+s.Title, http.StatusOK, view)
+ f := onSheet(s, "Share")
+ f.ret = "/sheets/" + strconv.FormatUint(s.ID, 10) + "/share"
+ a.render(w, r, "share", f, http.StatusOK, view)
}
// noticeText maps a code to a fixed sentence.
diff --git a/internal/sheets/handlers_sheets.go b/internal/sheets/handlers_sheets.go
index b96ff90..75f98c1 100644
--- a/internal/sheets/handlers_sheets.go
+++ b/internal/sheets/handlers_sheets.go
@@ -6,6 +6,8 @@ import (
"net/http"
"strconv"
+ "amadan.net/oficina/estilo.git/chrome"
+ estilomenu "amadan.net/oficina/estilo.git/menu"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
@@ -92,7 +94,7 @@ func (a *App) handleSheetList(w http.ResponseWriter, r *http.Request) {
func (a *App) renderSheetList(w http.ResponseWriter, r *http.Request, status int) {
viewer, ok := a.actor(r)
if !ok {
- http.Error(w, "Sign in first.", http.StatusForbidden)
+ a.forbidden(w, r)
return
}
list, err := a.listSheets(viewer)
@@ -118,8 +120,15 @@ func (a *App) renderSheetList(w http.ResponseWriter, r *http.Request, status int
for _, s := range list {
rows = append(rows, sheetRow{SheetSummary: s, Preview: a.previewFor(s)})
}
- a.render(w, r, "sheets", "All sheets — Oficina Sheets", status,
- sheetListView{Sheets: rows})
+ // Import and Create are the list's page actions: buttons in the bar
+ // at wide widths, rows in its Menu sheet below 800px. Create is a
+ // POST in both places (estilo's chrome.Action): it makes a sheet
+ // called "Untitled sheet" in one click and 303s into it.
+ f := frame{actions: []chrome.Action{
+ {Item: estilomenu.Item{Href: "/sheets/import", Label: "Import"}},
+ {Item: estilomenu.Item{Action: "/sheets", Label: "Create"}, Primary: true},
+ }}
+ a.render(w, r, "sheets", f, status, sheetListView{Sheets: rows})
}
// handleSheetCreate makes a sheet and sends you to it.
@@ -135,7 +144,7 @@ func (a *App) renderSheetList(w http.ResponseWriter, r *http.Request, status int
func (a *App) handleSheetCreate(w http.ResponseWriter, r *http.Request) {
actor, ok := a.actor(r)
if !ok {
- http.Error(w, "Sign in first.", http.StatusForbidden)
+ a.forbidden(w, r)
return
}
title := r.FormValue("title")
@@ -191,7 +200,18 @@ func (a *App) handleSheetPage(w http.ResponseWriter, r *http.Request) {
view.Warned = warnedCells(h, idx, view.Rows, view.Cols)
h.Release()
}
- a.render(w, r, "sheet", s.Title+" — Oficina Sheets", http.StatusOK, view)
+ f := onSheet(s, "")
+ f.fill = true
+ if view.MayWrite {
+ // The rename is the bar's editable title (estilo's TitleEdit),
+ // posting to the same route it always has.
+ f.edit = &chrome.TitleEdit{
+ Action: "/sheets/" + strconv.FormatUint(s.ID, 10) + "/rename",
+ Name: "title", Value: s.Title,
+ Label: "Sheet title", Submit: "Rename",
+ }
+ }
+ a.render(w, r, "sheet", f, http.StatusOK, view)
}
// windowGrowRoom is the empty space kept below and to the right of a
@@ -258,20 +278,3 @@ func (a *App) sheetFromURL(w http.ResponseWriter, r *http.Request) (SheetSummary
}
return s, true
}
-
-// notFound answers 404 with nothing that could name a sheet.
-func (a *App) notFound(w http.ResponseWriter, _ *http.Request) {
- http.Error(w, "Not found.", http.StatusNotFound)
-}
-
-// fail answers 500.
-//
-// The error is deliberately dropped rather than shown or logged: it can
-// carry a sealed title's plaintext, a cell's contents, or a SQL
-// statement quoting either, and `carlos logs` is platform-held storage
-// we cannot grep or redact (AGENTS.md / Mechanical rules). A code and a
-// coordinate are what an error is allowed to carry here, and this one
-// has neither to give.
-func (a *App) fail(w http.ResponseWriter, _ *http.Request, _ error) {
- http.Error(w, "Something went wrong.", http.StatusInternalServerError)
-}
diff --git a/internal/sheets/handlers_table.go b/internal/sheets/handlers_table.go
index 55c3d86..9e25c47 100644
--- a/internal/sheets/handlers_table.go
+++ b/internal/sheets/handlers_table.go
@@ -145,7 +145,7 @@ func (a *App) handleTable(w http.ResponseWriter, r *http.Request) {
view.Rows = a.tableRowsIn(h, applyView(h, spec, first, tableRowsPerPage), editR, editC)
view.Warned = warnedCells(h, newPosIndex(h), maxWindowRows, tableCols)
- a.render(w, r, "table", s.Title+" — table view", http.StatusOK, view)
+ a.render(w, r, "table", onSheet(s, "Table view"), http.StatusOK, view)
}
// handleTableWrite is the scriptless write path.
@@ -162,7 +162,7 @@ func (a *App) handleTableWrite(w http.ResponseWriter, r *http.Request) {
}
actor, okActor := a.actor(r)
if !okActor {
- http.Error(w, "Sign in first.", http.StatusForbidden)
+ a.forbidden(w, r)
return
}
// sheetFromURL proved the viewer may READ. Writing needs more, and
@@ -184,7 +184,7 @@ func (a *App) handleTableWrite(w http.ResponseWriter, r *http.Request) {
}
raw := r.FormValue("raw")
if len(raw) > maxRawBytes {
- http.Error(w, "Cell input too long.", http.StatusRequestEntityTooLarge)
+ a.badRequest(w, r, http.StatusRequestEntityTooLarge, "Cell input too long.")
return
}
@@ -207,7 +207,7 @@ func (a *App) handleTableWrite(w http.ResponseWriter, r *http.Request) {
}
rep, err := h.SetReport(rowID, colID, raw, actor.ID)
if err != nil {
- http.Error(w, "That cell could not be written.", http.StatusBadRequest)
+ a.badRequest(w, r, http.StatusBadRequest, "That cell could not be written.")
return
}
a.publishReport(s, h, rep)
diff --git a/internal/sheets/home.go b/internal/sheets/home.go
index b9928a2..1d869ff 100644
--- a/internal/sheets/home.go
+++ b/internal/sheets/home.go
@@ -46,8 +46,14 @@ func (a *App) ConfigureHome(raw *connect.Credentials) error {
}
render := func(w http.ResponseWriter, r *http.Request, state, label string, status int) {
w.Header().Set("Cache-Control", "no-store")
- p := connect.Page(state, label)
- a.render(w, r, "home_link", p.Title, status, p)
+ if state == "signedout" {
+ // The suite's signed-out page (spec §9.2), not connect's
+ // "Sign in with Home" one.
+ a.renderSignedOut(w, r, status)
+ return
+ }
+ p := connect.Page(state, label, func(k string) string { return homeCopy[k] })
+ a.render(w, r, "home_link", titled(p.Title), status, p)
}
bridge, err := connect.New(connect.Config{DB: d, Origin: a.origin, Issuer: settings.Issuer, ConsumerID: settings.ConsumerID, Namespace: "oficina/sheets", Keys: settings.Keys, DEK: settings.DEK, SessionCookie: sess.CookieName(), ReadSession: read,
MintHomeSession: func(w http.ResponseWriter, r *http.Request, s connect.Session) error {
@@ -71,6 +77,7 @@ func (a *App) ConfigureHome(raw *connect.Credentials) error {
return errHome
}
a.home, a.homeRoutes, a.homeSessions = bridge, routes, sess
+ a.directory = settings.Directory
return nil
}
@@ -152,7 +159,7 @@ func (a *App) homeLocalComplete(w http.ResponseWriter, r *http.Request) {
func (a *App) homeLocalBegin(w http.ResponseWriter, r *http.Request) {
connect.OAuthContinuation(a.origin, a.handleBegin, func(w http.ResponseWriter, r *http.Request, destination string) {
p := connect.OAuthPage(destination)
- a.render(w, r, "home_oauth", p.Title, http.StatusOK, p)
+ a.render(w, r, "home_oauth", frame{parts: []string{p.Title}, signinPage: true}, http.StatusOK, p)
})(w, r)
}
diff --git a/internal/sheets/home_navigation_test.go b/internal/sheets/home_navigation_test.go
index 6998448..9f36663 100644
--- a/internal/sheets/home_navigation_test.go
+++ b/internal/sheets/home_navigation_test.go
@@ -44,9 +44,14 @@ func TestHomeLocalPluginRoundTripAndSignout(t *testing.T) {
t.Fatal("sign-in seeded content", count)
}
f.link(t, "round-trip")
+ // With Home configured, the app's Sign out ends the app session and
+ // then chains to the credential origin (home/connect 01166fc, spec
+ // §9.5): a 307, so the POST survives the hop, to {Issuer}/signout.
+ // That hop is what stops the signed-out page's "Sign in" passing
+ // silently back through a live id.* session.
out := f.request(t, "POST", "/signout", nil, false)
- if out.Code != 303 || out.Header().Get("Location") != "/signed-out" {
- t.Fatal("signout", out.Code)
+ if out.Code != http.StatusTemporaryRedirect || out.Header().Get("Location") != homeTestIssuer+"/signout" {
+ t.Fatal("signout", out.Code, out.Header().Get("Location"))
}
if page := f.request(t, "GET", "/signed-out", nil, false); page.Code != 200 || page.Header().Get("Location") != "" {
t.Fatal("signout restarted Home")
diff --git a/internal/sheets/home_routes.go b/internal/sheets/home_routes.go
index 880650f..6aecf88 100644
--- a/internal/sheets/home_routes.go
+++ b/internal/sheets/home_routes.go
@@ -20,6 +20,7 @@ func (a *App) mountAuth(r chi.Router) {
r.Get("/signed-out", a.homeRoutes.SignedOut)
} else {
r.Get("/signin", a.handleSignin)
+ r.Get("/signed-out", a.handleSignedOut)
}
if a.auth != nil {
// The four handlers rastrillo/auth expects an app to mount, at
@@ -36,7 +37,7 @@ func (a *App) mountAuth(r chi.Router) {
r.Post("/signin", a.handleBegin)
r.Get("/auth/callback", a.auth.Callback)
r.Get("/auth/verify", a.auth.Verify)
- r.Post("/signout", a.auth.Signout)
+ r.Post("/signout", a.signOutToPage)
}
}
}
diff --git a/internal/sheets/home_test.go b/internal/sheets/home_test.go
index 7e4b6e5..8526e34 100644
--- a/internal/sheets/home_test.go
+++ b/internal/sheets/home_test.go
@@ -26,6 +26,13 @@ type homeFixture struct {
}
func newHomeFixture(t *testing.T) *homeFixture {
+ t.Helper()
+ return newHomeFixtureWith(t, "")
+}
+
+// newHomeFixtureWith is newHomeFixture with the hub directory origin set
+// (HOME_DIRECTORY_ORIGIN), which is what turns the app switcher on.
+func newHomeFixtureWith(t *testing.T, directory string) *homeFixture {
t.Helper()
app, _ := appWithOrigin(t, testOrigin)
key, err := assertioncrypto.Generate()
@@ -33,7 +40,7 @@ func newHomeFixture(t *testing.T) *homeFixture {
t.Fatal(err)
}
keys, _ := json.Marshal(map[string]string{"key": base64.StdEncoding.EncodeToString(key.SignPub())})
- cfg := connect.Credentials{Issuer: homeTestIssuer, ConsumerID: "sheets-preview", KeysBase64: base64.StdEncoding.EncodeToString(keys), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{81}, 32))}
+ cfg := connect.Credentials{Issuer: homeTestIssuer, ConsumerID: "sheets-preview", KeysBase64: base64.StdEncoding.EncodeToString(keys), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{81}, 32)), Directory: directory}
d, err := app.db.DB()
if err != nil {
t.Fatal(err)
diff --git a/internal/sheets/jobpages.go b/internal/sheets/jobpages.go
index 41731ea..9fb3ea0 100644
--- a/internal/sheets/jobpages.go
+++ b/internal/sheets/jobpages.go
@@ -49,7 +49,7 @@ func jobStatusData(d jobs.PageData) map[string]any {
// here by itself once the job is done and has somewhere to go, so this
// only ever draws a job that is still running or has failed.
func (a *App) renderJobPage(w http.ResponseWriter, r *http.Request, d jobs.PageData) {
- a.render(w, r, "job", "Importing — Oficina Sheets", http.StatusOK, jobPageView{
+ a.render(w, r, "job", titled("Importing a spreadsheet"), http.StatusOK, jobPageView{
Status: jobStatusData(d),
Running: d.Job.Status == jobs.Running,
PollSeconds: d.PollSeconds,
diff --git a/internal/sheets/page.go b/internal/sheets/page.go
index f546fc9..a41f0e4 100644
--- a/internal/sheets/page.go
+++ b/internal/sheets/page.go
@@ -28,12 +28,35 @@ import (
// SignedInPath "/sheets" so a fresh click never comes through here at
// all, and this redirect covers somebody who later types the bare
// address. Neither alone is enough.
+//
+// With Home configured the signed-out half goes too: "/" 303s to
+// /signin, which starts the Home exchange (Home #74, decision 2). That is
+// what the app switcher's single-team hop now lands on — Home sends a
+// person to the team instance's root rather than to /auth/start — and a
+// landing page there would leave them one click short of the sign-in the
+// hop used to run for them. Docs and Calendar already answer their root
+// this way. Standalone keeps the landing page: there is no Home to hand
+// off to, and it is where a stranger learns what this instance is.
+//
+// With Home, "/" goes through homeRequire, the guard every signed-in page
+// uses, rather than a bare identity check. A Home session whose member
+// lost admission is refused by identity but is still a live cookie, and
+// connect's /signin skips the form for a live cookie: redirecting such a
+// person straight to /signin sent them back here, forever (Astra, #39).
+// The guard ends that session first, keeps its 503 for a storage failure,
+// and sends a signed-out visitor to /signin?return_to=%2F.
func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) {
+ if a.home != nil {
+ a.homeRequire(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/sheets", http.StatusSeeOther)
+ })).ServeHTTP(w, r)
+ return
+ }
if _, ok := a.identity(r); ok {
http.Redirect(w, r, "/sheets", http.StatusSeeOther)
return
}
- a.render(w, r, "index", "Oficina Sheets", http.StatusOK, nil)
+ a.render(w, r, "index", frame{}, 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 c99a5c3..6c3be17 100644
--- a/internal/sheets/page_test.go
+++ b/internal/sheets/page_test.go
@@ -4,6 +4,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
+ "regexp"
"strings"
"testing"
)
@@ -33,9 +34,13 @@ func TestIndexServesHTML(t *testing.T) {
// markup whose switcher did nothing at all. A test can only pin a
// mistake it shares. The scheme is pinned in theme_test.go now,
// against the toggle selectors in theme.css itself.
+ //
+ // "Oficina Sheets" went in round 2 (shared chrome spec §10: the app
+ // is "Sheets"), and the way in is the bar's own Sign in, carrying
+ // the page to come back to, rather than a prose link in the page.
for _, want := range []string{
- "<h1>Oficina Sheets</h1>",
- `<a href="/signin">`, // an anonymous visitor must be able to get in
+ "<h1>Sheets</h1>",
+ `<a rst-btn href="/signin?return_to=%2F" estilo-bar-signin>Sign in</a>`, // an anonymous visitor must be able to get in
} {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("body missing %q", want)
@@ -53,17 +58,37 @@ func TestIndexDoesNotClaimNothingElseExists(t *testing.T) {
}
}
+// TestIndexIsNotACatchAll: only "/" renders the index. Every other
+// unknown path is a 404 — the error page for a browser (shared chrome
+// spec §3.3; this used to pin chi's plain "404 page not found"), and
+// plain text for anything that did not ask for HTML.
func TestIndexIsNotACatchAll(t *testing.T) {
for _, path := range []string{"/nope", "/api/sheets", "/healthz/x"} {
- if code := get(t, path).Code; code != http.StatusNotFound {
- t.Errorf("GET %s = %d, want 404", path, code)
+ rec := get(t, path)
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("GET %s = %d, want 404", path, rec.Code)
+ }
+ if strings.Contains(rec.Body.String(), "<h1>Sheets</h1>") {
+ t.Errorf("GET %s rendered the index", path)
+ }
+ if strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") {
+ t.Errorf("GET %s without Accept: text/html got a page", path)
+ }
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ req.Header.Set("Accept", "text/html,*/*;q=0.8")
+ page := httptest.NewRecorder()
+ New(Config{}).Router().ServeHTTP(page, req)
+ if page.Code != http.StatusNotFound || !strings.Contains(page.Body.String(), "<title>Page not found · Sheets</title>") {
+ t.Errorf("GET %s as a browser = %d, want the 404 page", path, page.Code)
}
}
}
// TestIndexLeaksNothing: the one rule reaches even a static page.
func TestIndexLeaksNothing(t *testing.T) {
- body := get(t, "/").Body.String()
+ // Fingerprinted asset URLs are dropped first: a content hash is hex,
+ // and "chrome.db41….css" is not a database file.
+ body := regexp.MustCompile(`(href|src)="/static/[^"]*"`).ReplaceAllString(get(t, "/").Body.String(), "")
for _, forbidden := range []string{"/home/", "oncarlos.com", "sqlite", ".db"} {
if strings.Contains(strings.ToLower(body), strings.ToLower(forbidden)) {
t.Errorf("page contains %q", forbidden)
@@ -181,7 +206,7 @@ func TestAnonymousRootStillOffersAWayIn(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("GET / anonymous = %d, want 200", rec.Code)
}
- if !strings.Contains(rec.Body.String(), `<a href="/signin">`) {
+ if !strings.Contains(rec.Body.String(), `estilo-bar-signin>Sign in</a>`) {
t.Error("anonymous root page offers no way in")
}
}
diff --git a/internal/sheets/render.go b/internal/sheets/render.go
index d7fbe30..60e4d6b 100644
--- a/internal/sheets/render.go
+++ b/internal/sheets/render.go
@@ -11,6 +11,9 @@ import (
"strings"
"testing/fstest"
+ "amadan.net/oficina/estilo.git/base"
+ "amadan.net/oficina/estilo.git/chrome"
+ estilomenu "amadan.net/oficina/estilo.git/menu"
"amadan.net/oficina/estilo.git/theme"
"github.com/carlosframework/rastrillo"
@@ -61,6 +64,12 @@ func sharedStaticFiles() fs.FS {
panic(err)
}
files["static/theme.css"] = &fstest.MapFile{Data: theme.CSS()}
+ // estilo's shared chrome, served under the app's own fingerprinted
+ // /static like the theme, in the suite's order: base, menu, chrome.
+ files["static/estilo/base.css"] = &fstest.MapFile{Data: base.CSS()}
+ files["static/estilo/menu.css"] = &fstest.MapFile{Data: estilomenu.CSS()}
+ files["static/estilo/chrome.css"] = &fstest.MapFile{Data: chrome.CSS()}
+ files["static/estilo/chrome.js"] = &fstest.MapFile{Data: chrome.JS()}
return files
}
@@ -102,6 +111,8 @@ func parsePages() map[string]*template.Template {
t := template.Must(template.New(name).Funcs(funcs).
ParseFS(ui.Templates(), "*.html"))
t = template.Must(t.ParseFS(connect.Templates(), "templates/*.html"))
+ t = template.Must(t.ParseFS(estilomenu.Templates(), "templates/*.html"))
+ t = template.Must(t.ParseFS(chrome.Templates(), "templates/*.html"))
t = template.Must(t.ParseFS(templatesFS, "templates/"+layoutFile, f))
out[name] = t
}
@@ -110,74 +121,48 @@ func parsePages() map[string]*template.Template {
// PageData is what every template receives.
//
-// The light/dark choice is resolved here, server-side, from a cookie —
-// never fetched. AGENTS.md: it must resolve before first paint, because
-// cell fills resolve against the theme and a late preference repaints
-// every coloured cell. A page carrying it in its first byte cannot
-// flash; one that asks for it can.
+// Frame is the chrome, built once per request in chrome.go: the bar, the
+// <title> parts and the light/dark choice. The choice is resolved there,
+// server-side, from a cookie — never fetched. AGENTS.md: it must resolve
+// before first paint, because cell fills resolve against the theme and a
+// late preference repaints every coloured cell. A page carrying it in
+// its first byte cannot flash; one that asks for it can.
type PageData struct {
- HomeLinkLabel string
- Title string
- // Mode is the viewer's light/dark choice — light, dark or auto.
- // ThemeAttr is what <html data-theme> gets, and is EMPTY for auto:
- // rastrillo's toggle is two rules keyed on that attribute, and no
- // attribute is how "follow the OS" is spelled.
- Mode string
- ThemeAttr string
- Address string
- SignedIn bool
- Path string
+ Frame Frame
// 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)
- mode := modeFrom(r)
- homeLabel := ""
- if a.home != nil {
- homeLabel = connect.English["menu_action"]
- }
- return PageData{
- HomeLinkLabel: homeLabel,
- Title: title,
- Mode: mode,
- ThemeAttr: themeAttr(mode),
- Address: address,
- SignedIn: signedIn,
- Path: r.URL.Path,
- Data: data,
- }
-}
-
-// render writes a page.
+// render writes a page. f describes its chrome: the <title> parts, the
+// open sheet, the bar's actions (see chrome.go).
//
// 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) {
+func (a *App) render(w http.ResponseWriter, r *http.Request, page string, f frame, status int, data any) {
if nativePage(w, r, page, status, data) {
return
}
t, ok := pages[page]
if !ok {
- http.Error(w, "Something went wrong.", http.StatusInternalServerError)
+ a.fail(w, r, errNoTemplate)
return
}
var buf bytes.Buffer
- if err := t.ExecuteTemplate(&buf, "layout", a.pageData(r, title, data)); err != nil {
+ if err := t.ExecuteTemplate(&buf, "layout", PageData{Frame: a.frameFor(r, f), Data: 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)
+ // error is dropped, because an html/template error can quote the
+ // data it was rendering (AGENTS.md / Mechanical rules). The 500
+ // page is a template too; if the layout is what failed, it falls
+ // back to plain text itself (errorPage).
+ a.fail(w, r, err)
return
}
+ pageHeaders(w)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
w.Write(buf.Bytes())
diff --git a/internal/sheets/render_test.go b/internal/sheets/render_test.go
index 9343ee8..d62d6b7 100644
--- a/internal/sheets/render_test.go
+++ b/internal/sheets/render_test.go
@@ -63,7 +63,7 @@ func TestPagesLinkFingerprintedAssets(t *testing.T) {
func TestRenderOfAnUnknownPageIs500NotAPanic(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
- New(Config{}).render(rec, req, "no-such-page", "T", http.StatusOK, nil)
+ New(Config{}).render(rec, req, "no-such-page", titled("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/sharenotice.go b/internal/sheets/sharenotice.go
index a0839db..7288c77 100644
--- a/internal/sheets/sharenotice.go
+++ b/internal/sheets/sharenotice.go
@@ -71,3 +71,24 @@ func (a *App) revokedTeamNotice(r *http.Request, sheetID uint64) string {
return "The explicit permission is removed, but since " + m.Address +
" is on the team, they still have access."
}
+
+// noticeTone is the rastrillo callout tone for a notice code on the
+// share, comments and history pages (shared chrome spec §4). They were
+// all one neutral grey callout, so "Access removed." and "Unknown role."
+// looked the same.
+//
+// - positive: the thing asked for happened.
+// - negative: it did not, and the form needs something different.
+// - warning: it happened, and there is something to know about it.
+// - info: nothing happened, and nothing was wrong.
+func noticeTone(code string) string {
+ switch code {
+ case "shared", "revoked", "link-revoked", "added", "resolved", "restored", "undone":
+ return "positive"
+ case "bad-address", "bad-role", "last-owner", "bad-link-role", "empty", "no-cell", "gone":
+ return "negative"
+ case noticeRevokedTeam, "link":
+ return "warning"
+ }
+ return "info"
+}
diff --git a/internal/sheets/signin.go b/internal/sheets/signin.go
index 1f7e01b..872f76f 100644
--- a/internal/sheets/signin.go
+++ b/internal/sheets/signin.go
@@ -1,76 +1,177 @@
package sheets
-import "net/http"
+import (
+ "net/http"
+ "strings"
-// signinMessage maps auth's redirect vocabulary to what the person in
-// front of the screen needs to do next.
+ "amadan.net/oficina/estilo.git/brand"
+ "amadan.net/oficina/estilo.git/chrome"
+ "github.com/carlosframework/rastrillo/sessions"
+)
+
+// The sign-in pages are estilo's (estilo-signin, estilo-signin-sent,
+// estilo-signed-out) with the suite's copy (shared chrome spec §9.2):
+// "Sign in", "Email me a link", the 15-minute line, and a sent state that
+// replaces the form rather than sitting above a still-primary button that
+// invites a second send. "Sign in to Oficina Sheets" is gone.
+
+// linkHelp is the line under the address field, and the sent page's. The
+// lifetime is keymaildev/signin's LinkTTL default, which rastrillo's auth
+// does not override.
+const linkHelp = "We’ll email you a link that signs you in. It works once and expires in 15 minutes."
+
+// signinError maps auth's redirect vocabulary to what the person in
+// front of the screen needs to do next. It SELECTS from a fixed set —
+// there is no path from the URL into the page.
//
// The default case is the point of this function. On the sign-in page
// every outcome means one operational thing — we could not sign you in,
// ask for a new link — so an unrecognised code must say that too, not
-// fall through to an app-wide "something went wrong".
+// fall through to an app-wide "something went wrong". The codes that
+// actually reach it are ?err=1 (an internal failure in Begin) and any
+// future code auth adds.
//
-// The codes that actually reach the default are ?err=1 (an internal
-// failure in Begin) and, before it was handled explicitly below,
-// ?err=keymail.
+// field is true for the one error that belongs in the address field's
+// own slot rather than a callout above the form (§9.2).
//
// An earlier version of this comment claimed a second click on an
-// expired link fell through here. It does not: auth redirects
-// ?err=expired from all three of its not-found paths, and rastrillo's
-// own auth_test.go asserts it for the second Verify. That claim came
-// to me from Docs, who had inferred it from TakeLink deleting the row
-// without reading the redirect vocabulary at the other end; they
-// corrected it, and it is recorded here because the wrong version was
-// briefly the justification for this function.
-func signinMessage(q map[string][]string) string {
- first := func(k string) string {
- if v := q[k]; len(v) > 0 {
- return v[0]
- }
- return ""
- }
- if first("sent") != "" {
- return "Check your email. The link signs you in and works once."
- }
- switch first("err") {
+// expired link fell through to the default. It does not: auth redirects
+// ?err=expired from all three of its not-found paths, and rastrillo's own
+// auth_test.go asserts it for the second Verify.
+func signinError(code string) (text string, field bool) {
+ switch code {
case "":
- return ""
+ return "", false
case "rate":
- return "Too many sign-in attempts. Wait a few minutes, then ask for a new link."
+ return "Too many attempts. Wait a few minutes, then try again.", false
case "address":
- return "That address cannot sign in to this instance. Ask whoever runs it to add you."
+ // rastrillo's ?err=address is an address it could not parse.
+ // A refused (unadmitted) address is never told so: handleBegin
+ // answers it exactly as it answers an admitted one.
+ return "That doesn’t look like an email address.", true
case "expired":
- return "That link has expired or was already used. Ask for a new one below."
+ return "That link has expired or was already used. Enter your email to get a new one.", false
case "keymail":
- return "Your keymail sign-in could not be completed. Ask for an emailed link instead."
+ return "Your keymail sign-in couldn’t be completed. Enter your email to get a link instead.", false
default:
- return "We could not sign you in. Ask for a new link below."
+ return "We couldn’t sign you in. Enter your email to get a new link.", false
}
}
-// 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.
+// signinView is the sign-in page's own payload: the form, or the sent
+// state that replaces it.
type signinView struct {
- Message string
- Force bool
+ Form chrome.SignIn
+ Sent *chrome.SignInNotice
+}
+
+// signinFormPath is where the local sign-in form lives. With Home
+// configured, bare /signin starts the Home exchange and the local form is
+// /signin/local; standalone, /signin is the form.
+func (a *App) signinFormPath() string {
+ if a.home != nil {
+ return "/signin/local"
+ }
+ return "/signin"
}
// handleSignin serves the sign-in page.
+//
+// Standalone, somebody already signed in never sees the form (§9.2's
+// last row): /signin answers 303 to a safe return_to or the list. With
+// Home configured this handler is the LOCAL form behind /signin/local,
+// which a person with a Home session reaches on purpose to link an
+// existing account, so it never skips there; connect's own /signin does
+// the skip in that mode.
func (a *App) handleSignin(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
- a.render(w, r, "signin", "Sign in — Oficina Sheets", http.StatusOK, signinView{
- Message: signinMessage(q),
- Force: q.Get("force") != "",
+ if a.home == nil && q.Get("sent") == "" && q.Get("err") == "" && q.Get("force") == "" {
+ if _, ok := a.identity(r); ok {
+ http.Redirect(w, r, signedInLanding(r), http.StatusSeeOther)
+ return
+ }
+ }
+ if q.Get("sent") != "" {
+ w.Header().Set("Cache-Control", "no-store")
+ a.render(w, r, "signin", frame{parts: []string{"Check your email"}, signinPage: true}, http.StatusOK, signinView{
+ Sent: &chrome.SignInNotice{
+ Icon: brand.Path("sheets"),
+ Heading: "Check your email",
+ // rastrillo's Begin redirects to ?sent=1 with no address,
+ // so the page cannot echo it (Docs made the same call).
+ Body: "If that address can sign in here, we’ve sent it a link. It works once and expires in 15 minutes.",
+ Link: &chrome.Link{Href: a.signinFormPath(), Label: "Use a different address"},
+ },
+ })
+ return
+ }
+ text, field := signinError(q.Get("err"))
+ form := chrome.SignIn{
+ Icon: brand.Path("sheets"),
+ Heading: "Sign in",
+ Action: "/signin",
+ AddressLabel: "Email address",
+ Help: linkHelp,
+ Submit: "Email me a link",
+ }
+ if field {
+ form.AddressError = text
+ } else {
+ form.Error = text
+ }
+ // 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 q.Get("force") != "" {
+ form.Hidden = []chrome.Hidden{{Name: "force", Value: "1"}}
+ }
+ a.render(w, r, "signin", frame{parts: []string{"Sign in"}, signinPage: true}, http.StatusOK, signinView{Form: form})
+}
+
+// signedInLanding is where an already-signed-in /signin goes: the
+// request's return_to when rastrillo's SafeReturn accepts it and it is
+// not itself a sign-in page (which would loop), otherwise the list.
+func signedInLanding(r *http.Request) string {
+ target := sessions.SafeReturn(r, "/sheets")
+ if target == "/" || strings.HasPrefix(target, "/signin") || strings.HasPrefix(target, "/auth/") || strings.HasPrefix(target, "/signed-out") {
+ return "/sheets"
+ }
+ return target
+}
+
+// renderSignedOut is where Sign out lands: /signed-out, in both modes.
+// With Home configured connect's route renders it through ConfigureHome's
+// callback; standalone, signOutToPage sends Sign out here.
+func (a *App) renderSignedOut(w http.ResponseWriter, r *http.Request, status int) {
+ w.Header().Set("Cache-Control", "no-store")
+ a.render(w, r, "signed_out", frame{parts: []string{"Signed out"}, signinPage: true}, status, chrome.SignInNotice{
+ Icon: brand.Path("sheets"),
+ Heading: "You’re signed out",
+ Body: "You’ve signed out of Sheets on this device.",
+ Link: &chrome.Link{Href: "/signin", Label: "Sign in"},
})
}
+
+func (a *App) handleSignedOut(w http.ResponseWriter, r *http.Request) {
+ a.renderSignedOut(w, r, http.StatusOK)
+}
+
+// signOutToPage is rastrillo's Sign out, landing on /signed-out instead of
+// the sign-in form. auth.Signout ends the session and 303s to its
+// SigninPath; this rewrites only that one Location, so everything else
+// auth answers (its cross-origin refusal) is untouched.
+func (a *App) signOutToPage(w http.ResponseWriter, r *http.Request) {
+ a.auth.Signout(&landing{ResponseWriter: w}, r)
+}
+
+type landing struct{ http.ResponseWriter }
+
+func (l *landing) WriteHeader(code int) {
+ if code == http.StatusSeeOther && l.Header().Get("Location") == "/signin" {
+ l.Header().Set("Location", "/signed-out")
+ }
+ l.ResponseWriter.WriteHeader(code)
+}
diff --git a/internal/sheets/signin_test.go b/internal/sheets/signin_test.go
index 75a7626..5daf4a8 100644
--- a/internal/sheets/signin_test.go
+++ b/internal/sheets/signin_test.go
@@ -12,14 +12,12 @@ import (
"github.com/carlosframework/rastrillo/migrate"
)
-// TestSigninMessageNeverFallsThroughSilently is the reason this function
+// TestSigninErrorNeverFallsThroughSilently is the reason signinError
// exists. Every outcome on this page means one operational thing, so an
-// unrecognised code must still tell the person what to do — the second
-// click on an expired link produces exactly such a code, and it is the
-// commonest real failure.
-func TestSigninMessageNeverFallsThroughSilently(t *testing.T) {
+// unrecognised code must still tell the person what to do.
+func TestSigninErrorNeverFallsThroughSilently(t *testing.T) {
for _, code := range []string{"1", "keymail", "expired", "rate", "address", "wat", "0"} {
- msg := signinMessage(map[string][]string{"err": {code}})
+ msg, _ := signinError(code)
if msg == "" {
t.Errorf("err=%s produced no message", code)
continue
@@ -27,22 +25,34 @@ func TestSigninMessageNeverFallsThroughSilently(t *testing.T) {
// Every error path has to leave a way forward.
low := strings.ToLower(msg)
if !strings.Contains(low, "new link") && !strings.Contains(low, "new one") &&
- !strings.Contains(low, "emailed link") && !strings.Contains(low, "add you") {
+ !strings.Contains(low, "a link instead") && !strings.Contains(low, "try again") &&
+ !strings.Contains(low, "email address") {
t.Errorf("err=%s says %q, with no next step", code, msg)
}
}
+ if msg, _ := signinError(""); msg != "" {
+ t.Errorf("bare page gave %q, want no message", msg)
+ }
}
-func TestSigninMessageSentAndEmpty(t *testing.T) {
- if msg := signinMessage(map[string][]string{"sent": {"1"}}); !strings.Contains(msg, "Check your email") {
- t.Errorf("sent=1 gave %q", msg)
+// The sent state replaces the form (spec §9.2), rather than a notice
+// above a still-primary "Email me a link" that invites a second send.
+func TestSigninSentReplacesTheForm(t *testing.T) {
+ rec := httptest.NewRecorder()
+ New(Config{}).Router().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/signin?sent=1", nil))
+ body := rec.Body.String()
+ if !strings.Contains(body, "<h1>Check your email</h1>") || !strings.Contains(body, "<title>Check your email · Sheets</title>") {
+ t.Fatal("the sent state is not the Check your email page")
}
- if msg := signinMessage(map[string][]string{}); msg != "" {
- t.Errorf("bare page gave %q, want no message", msg)
+ if strings.Contains(body, `name="address"`) || strings.Contains(body, "Email me a link") {
+ t.Fatal("the sent state still shows the form")
+ }
+ if !strings.Contains(body, `<a href="/signin">Use a different address</a>`) {
+ t.Fatal("the sent state has no way back to the form")
}
}
-// TestSigninPageDoesNotEchoQuery: signinMessage selects from a fixed set
+// TestSigninPageDoesNotEchoQuery: signinError selects from a fixed set
// rather than echoing, so there is no path from the URL into the HTML.
func TestSigninPageDoesNotEchoQuery(t *testing.T) {
rec := httptest.NewRecorder()
@@ -54,7 +64,7 @@ func TestSigninPageDoesNotEchoQuery(t *testing.T) {
if strings.Contains(rec.Body.String(), "<script>") {
t.Error("query value reached the page")
}
- if !strings.Contains(rec.Body.String(), "could not sign you in") {
+ if !strings.Contains(rec.Body.String(), "couldn’t sign you in") {
t.Error("unknown err code did not get the fallback message")
}
}
diff --git a/internal/sheets/static/clipboard.js b/internal/sheets/static/clipboard.js
index 254b18a..c680b10 100644
--- a/internal/sheets/static/clipboard.js
+++ b/internal/sheets/static/clipboard.js
@@ -14,6 +14,8 @@
function say(text, tone) {
if (window.SheetsNote) window.SheetsNote.say(text, tone);
}
+ // The page names each chord in its platform's spelling (⌘C, Ctrl+C).
+ function key(name, fallback) { return surface.getAttribute('data-key-' + name) || fallback; }
// Copy offsets relative formulas; Cut moves raw formulas without translation.
// Text equality is only provenance within this focused window.
@@ -182,7 +184,7 @@
var text = copyText();
if (text === null || !navigator.clipboard) return;
navigator.clipboard.writeText(text).catch(function () {
- say('This browser would not let the menu copy. Press Ctrl+C instead.', 'warn');
+ say('This browser would not let the menu copy. Press ' + key('copy', 'Ctrl+C') + ' instead.', 'warn');
});
},
cut: function () {
@@ -195,16 +197,16 @@
navigator.clipboard.writeText(text).then(function () {
rememberCut(text, b);
}, function () {
- say('This browser would not let the menu cut. Press Ctrl+X instead.', 'warn');
+ say('This browser would not let the menu cut. Press ' + key('cut', 'Ctrl+X') + ' instead.', 'warn');
});
},
paste: function () {
if (!navigator.clipboard || !navigator.clipboard.readText) {
- say('This browser only pastes from the keyboard. Press Ctrl+V.', 'warn');
+ say('This browser only pastes from the keyboard. Press ' + key('paste', 'Ctrl+V') + '.', 'warn');
return;
}
navigator.clipboard.readText().then(paste, function () {
- say('Reading the clipboard needs permission this browser did not give. Press Ctrl+V instead.', 'warn');
+ say('Reading the clipboard needs permission this browser did not give. Press ' + key('paste', 'Ctrl+V') + ' instead.', 'warn');
});
}
};
diff --git a/internal/sheets/static/menubar.js b/internal/sheets/static/menubar.js
index 586e308..0ea49fb 100644
--- a/internal/sheets/static/menubar.js
+++ b/internal/sheets/static/menubar.js
@@ -8,9 +8,10 @@
* honest disclosure. This module ADDS the arrow keys on top; nothing
* here is load-bearing for the menu working at all.
*
- * rastrillo.js already closes menus on a click outside and on Escape,
- * and returns focus to the summary. None of that is repeated here — a
- * second Escape handler would move focus twice.
+ * estilo's chrome.js already closes the open menu on a click outside
+ * and on Escape, and returns focus to its title: every menu here is in
+ * the one estilo-menus group with the bar's own. None of that is
+ * repeated here — a second Escape handler would move focus twice.
*
* The one thing this module must not skip: stopping every key it
* consumes. Jspreadsheet binds arrow keys on `document` and acts
@@ -45,11 +46,11 @@
/* items lists what is reachable inside an open menu, in visual order.
* Hidden items are excluded — several are hidden until commands.js
- * wires them — and a closed submenu contributes its summary only, not
- * the items inside it. */
+ * wires them. There are no submenus: a nested <details> in a grouped
+ * one closes its parent (TestNoNestedExclusiveDetails). */
function items(details) {
var out = [];
- var panel = details.querySelector(':scope > [rst-dropdown-menu]');
+ var panel = details.querySelector(':scope > [estilo-menu]');
if (!panel) return out;
walk(panel);
return out;
@@ -58,15 +59,6 @@
Array.prototype.forEach.call(node.children, function (el) {
if (el.hidden || el.disabled || el.type === 'hidden') return;
if (el.tagName === 'FORM') { walk(el); return; }
- if (el.tagName === 'DETAILS') {
- var s = summaryOf(el);
- if (s) out.push(s);
- if (el.open) {
- var inner = el.querySelector(':scope > div');
- if (inner) walk(inner);
- }
- return;
- }
if (el.tagName === 'A' || el.tagName === 'BUTTON' || el.tagName === 'INPUT') out.push(el);
else if (el.children.length) walk(el);
});
@@ -104,14 +96,6 @@
return null;
}
- /* A submenu is a <details rst-menu-group>. It deliberately carries no
- * name= — grouped, opening it would close the top-level menu it lives
- * inside, and take itself down with it. */
- function submenuOf(el) {
- var d = el.closest('details[rst-menu-group]');
- return (d && summaryOf(d) === el) ? d : null;
- }
-
bar.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) return;
var target = e.target;
@@ -136,18 +120,9 @@
else move(menu, target, -1);
break;
case 'ArrowRight':
- var sub = submenuOf(target);
- if (sub && !sub.open) { sub.open = true; focusInside(sub); break; }
openMenu(step(menu, 1), (!isTop || menu.open) ? 'items' : 'summary');
break;
case 'ArrowLeft':
- var inner = target.closest('details[rst-menu-group]');
- if (inner && summaryOf(inner) !== target) {
- inner.open = false;
- summaryOf(inner).focus();
- break;
- }
- if (inner && inner.open) { inner.open = false; break; }
openMenu(step(menu, -1), (!isTop || menu.open) ? 'items' : 'summary');
break;
case 'Home':
@@ -190,11 +165,6 @@
list[(i + delta + list.length) % list.length].focus();
}
- function focusInside(details) {
- var first = details.querySelector(':scope > div a, :scope > div button');
- if (first) first.focus();
- }
-
/* Tab out closes. focusout rather than blur, because blur does not
* bubble and the focus may be leaving from any depth. The relatedTarget
* check is what distinguishes "moved to another item in this menu"
@@ -226,8 +196,7 @@
* menu; activating returns it to the grid, because the thing you asked
* for happened to a cell and that is where you want to be next.
*
- * Links and submenu summaries are excluded — a link is navigating away
- * and a summary is opening, not activating. */
+ * Links are excluded — a link is navigating away, not activating. */
bar.addEventListener('click', function (e) {
var btn = e.target.closest('button[data-cmd]');
if (!btn) return;
diff --git a/internal/sheets/static/modal.js b/internal/sheets/static/modal.js
new file mode 100644
index 0000000..4750db1
--- /dev/null
+++ b/internal/sheets/static/modal.js
@@ -0,0 +1,38 @@
+/* A modal dialog owns the keyboard.
+ *
+ * A native modal <dialog> makes the page behind it inert to the pointer
+ * and to focus, but not to key events: a keydown on the dialog's own
+ * button still travels to document and window, where commands.js,
+ * clipboard.js and the vendored grid all listen. So with the Keyboard
+ * shortcuts dialog open, Ctrl+D filled the selection down behind it and
+ * a letter key started an edit in a cell nobody could see — persisted
+ * writes made from a dialog (Astra, #39 finding 1).
+ *
+ * So while any modal dialog is open, key and clipboard events stop here,
+ * at the window in the capture phase, before any page listener hears
+ * them. Stopping propagation is not preventing the default: Tab still
+ * moves inside the dialog, Enter and Space still press its buttons,
+ * typing still reaches its fields and Escape still closes it. A dialog
+ * that needs key handling of its own adds it on the dialog element in
+ * the capture phase.
+ *
+ * Loaded first of the page's scripts, so its listener is registered
+ * before any other window listener. General on purpose: it is about
+ * every modal on every page, not about one dialog. */
+(function () {
+ 'use strict';
+
+ function modalOpen() {
+ try {
+ return !!document.querySelector('dialog:modal');
+ } catch (e) {
+ return !!document.querySelector('dialog[open]');
+ }
+ }
+
+ ['keydown', 'keypress', 'keyup', 'beforeinput', 'paste', 'cut', 'copy'].forEach(function (type) {
+ window.addEventListener(type, function (e) {
+ if (modalOpen()) e.stopImmediatePropagation();
+ }, true);
+ });
+}());
diff --git a/internal/sheets/static/sheets.css b/internal/sheets/static/sheets.css
index fed0a9c..b4b7693 100644
--- a/internal/sheets/static/sheets.css
+++ b/internal/sheets/static/sheets.css
@@ -14,32 +14,19 @@
* --rst-text-muted, --rst-tone-warning-fg and --rst-font.
*/
-/* 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.
- *
- * The shell becomes a viewport-height flex column ONLY on a page that
- * holds a sheet, so every other page still scrolls normally. :has() is
- * what scopes it without a marker class the template would have to
- * remember to set.
- *
- * The surface takes the remaining space with flex rather than
- * subtracting a topbar height. There is no token for that height, and a
- * guessed constant is a scrolling document at every size it was not
- * guessed for — which is exactly what `height: 100dvh` here produced:
- * a 771px document in a 720px viewport, off by the topbar. */
-[rst-shell-topbar]:has([data-sheet-surface]) {
- display: flex;
- flex-direction: column;
- height: 100dvh;
-}
-
+/* The grid surface fills what is left of the viewport under the bar.
+ *
+ * The shell does that part now: the grid page renders
+ * <div estilo-shell="fill">, and estilo's chrome.css makes that shell
+ * exactly the viewport, holds the bar at its 52px and gives the grid's
+ * <main> the rest (flex: 1, min-block-size: 0). This rule used to be
+ * [rst-shell-topbar]:has([data-sheet-surface]) { height: 100dvh } here,
+ * keyed on rastrillo's wrapper, and before that a guessed topbar height
+ * that made a 771px document in a 720px viewport. What is left is the
+ * surface's own column: the chrome strips and the grid. */
[data-sheet-surface] {
display: flex;
flex-direction: column;
- flex: 1;
- min-height: 0;
}
/* ── The chrome ──────────────────────────────────────────────────────
@@ -65,290 +52,82 @@
color: var(--rst-text);
}
-/* A page's own actions in the top bar, against the account menu.
- *
- * The auto margin has to live HERE and be taken off the account, not
- * added alongside it. rastrillo gives [rst-shell-account] a
- * margin-inline-start: auto, which absorbs all the bar's free space and
- * would leave Create stranded back beside the nav — measured, not
- * guessed: that is exactly how it first rendered. A second auto margin
- * does not fix it either, because flexbox splits free space equally
- * between auto margins and the buttons end up mid-bar.
- *
- * Only above 800px. Below it rastrillo stacks the tail into a column
- * behind the Menu disclosure and zeroes that same margin itself, and an
- * auto inline margin in a column flex box shoves one item to the
- * trailing edge — the bug the framework's own comment at that rule
- * describes. Same breakpoint as the framework's, deliberately: two
- * shells with two breakpoints is two systems. */
-[data-topbar-actions] {
- display: flex;
- align-items: center;
- gap: var(--rst-sp-2);
-}
-
-/* Both selectors are written against the framework's own shape, and
- * both have to out-specify it. rastrillo re-applies the account's auto
- * margin INSIDE this same media query at
- * [rst-shell-topbar] [rst-shell-tail] > [rst-shell-account], which is
- * 0-3-0; a plain [data-topbar-actions] ~ [rst-shell-account] is 0-2-0
- * and loses, which is not a subtle failure — the buttons come to rest
- * dead centre of the bar, because the two auto margins then split the
- * free space between them. Measured, twice.
- *
- * The tail is display: contents up here, so these are siblings in the
- * bar's own flex line even though they are nested in the markup. */
-@media (min-width: 800px) {
- [rst-shell-topbar] [rst-shell-tail] > [data-topbar-actions] {
- margin-inline-start: auto;
- }
- [rst-shell-topbar] [rst-shell-tail] > [data-topbar-actions] ~ [rst-shell-account] {
- margin-inline-start: 0;
- }
-}
-
-/* The document title, in the top bar beside the brand. It truncates
- * rather than wrapping: the bar is a fixed height, and a two-line title
- * would push the account menu out of it.
- *
- * The clipping lives on the WRAPPER, not on the text or the input. It
- * used to sit directly on [data-doc-title] back when that was a span,
- * and left there it would clip a rename input's own caret: type past
- * 22rem and the cursor walks out of a box with overflow: hidden, so you
- * are editing text you cannot see. */
-[data-doc-title] {
- color: var(--rst-text);
- font-weight: 600;
- max-inline-size: 22rem;
-}
-
-[data-doc-title-text] {
- display: block;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-/* The rename form. It reads as a title until somebody means to edit it:
- * no border, no field background, the same weight and colour as the
- * static version above. This is the one control on the page that has to
- * NOT look like a control at rest — a text input sitting permanently in
- * the top bar of a spreadsheet competes with the grid for "the thing
- * you type in", and the grid must win by default. */
-form[data-doc-title] {
- display: flex;
- align-items: center;
- gap: var(--rst-sp-1);
-}
-
-[data-doc-title-input] {
- min-inline-size: 0;
- padding: var(--rst-sp-1);
- border: 1px solid transparent;
- border-radius: var(--rst-radius);
- background: none;
- color: inherit;
- font: inherit;
- text-overflow: ellipsis;
-}
-
-[data-doc-title-input]:hover {
- border-color: var(--rst-line);
-}
-
-/* Focus turns it into an obvious field. :focus-visible is not enough
- * here — a click into the box must show the same affordance a Tab into
- * it does, because at rest the two are indistinguishable. */
-[data-doc-title-input]:focus {
- border-color: var(--rst-accent);
- background: var(--rst-surface);
- outline: none;
- text-overflow: clip;
-}
-
-/* The submit is hidden until the field is engaged, and hidden with
- * clip rather than display: none so it stays focusable — Tab from the
- * input must reach it. Scripting off, this button is the only way to
- * commit a rename that was not submitted with Enter. */
-form[data-doc-title]:not(:focus-within) button {
- position: absolute;
- inline-size: 1px;
- block-size: 1px;
- overflow: hidden;
- clip-path: inset(50%);
- white-space: nowrap;
-}
-
-[data-menubar] {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
-}
-
-[data-menubar] > [rst-dropdown] > summary {
- border-radius: var(--rst-radius-sm);
- font-size: var(--rst-fs-sm);
- padding: var(--rst-sp-1) var(--rst-sp-2);
-}
+/* The menu bar is estilo's ([estilo-menubar], [estilo-menu], estilo
+ * menu.css): square titles in the page flow, 9px floating panels, inset
+ * rows and a shortcut column. What was here — rastrillo dropdowns
+ * repositioned with an anchor-positioning override, 6px rounded titles
+ * with no hover, a 17rem panel and inline accordion submenus — went with
+ * it. Only what is Sheets' own is left below. */
-[data-menubar] > [rst-dropdown][open] > summary {
- background: var(--rst-accent-soft);
- color: var(--rst-accent);
-}
-
-/* rastrillo's dropdown panel is right-aligned, which is right for the
- * account menu it was written for and wrong for a menu bar: a File menu
- * hanging off the right edge of the word "File" opens away from the
- * item you clicked.
- *
- * This is the fallback branch, for an engine with no anchor
- * positioning. There rastrillo's panel is `position: absolute;
- * top: 100%; inset-inline-end: 0` and flipping the two insets is the
- * whole fix. The @supports block below is the branch that actually
- * runs in Chromium, and it must not be folded into this rule — see
- * its own comment for what happens when it is. */
-[data-menubar] [rst-dropdown-menu] {
- inset-inline-start: 0;
- inset-inline-end: auto;
- /* 17rem, not the 15rem this started at, and the extra 2rem is spent
- * on the two resize rows: label, field and button need 236px side by
- * side, and 15rem leaves 224. Widening the panel is the honest fix —
- * squeezing the field instead makes a number box you cannot read a
- * three-digit value in. */
- min-inline-size: 17rem;
- /* 15rem is 240px, and the menus nearest the right edge open from
- * there — so on a 320px viewport an open panel ran off the screen
- * even though the closed chrome fits. max wins over min in CSS, so
- * this bounds it without the panel becoming narrow on a wide one. */
- max-inline-size: calc(100vw - 1rem);
- /* rastrillo caps a panel at 20rem and scrolls past it, which is right
- * for a list nobody can predict the length of — a locale menu, a row
- * kebab. A menu bar's menus are a FIXED list we wrote, and Format's
- * closed height is 394px: the cap put "Clear formatting" behind a
- * scrollbar on every viewport, including a 900px-tall one with room
- * to spare. The dvh term is rastrillo's own and stays, so a short
- * window still scrolls rather than overflowing. */
- max-block-size: min(30rem, max(calc(100dvh - 6rem), 8rem));
-}
-
-/* Where anchor positioning exists, the two insets above are not a
- * refinement of rastrillo's placement — they DEFEAT it, and this is
- * the bug that put every menu against the right edge of the viewport.
- *
- * rastrillo v0.23.0 anchors its panel: `position: fixed`,
- * `position-anchor: --rst-menu`, `position-area: block-end
- * span-inline-start`, and `inset: auto` so the area does the placing.
- * That `inset: auto` is (0,1,0). Ours above is (0,2,0), so it wins —
- * and an anchored, fixed panel with inset-inline-start resolved to 0
- * takes the VIEWPORT as its containing block. Measured 2026-09-03 on a
- * 1280px viewport: all six panels landed at x=1015, flush right,
- * whichever word you clicked. The menu bar looked broken and the CSS
- * looked correct.
- *
- * So the fix is to hand the placement back and change the AREA, not
- * the insets. `span-inline-end` is the one word that differs from
- * rastrillo's: its account menu sits at the right edge and opens
- * leftward, and a menu bar sits at the left and opens rightward.
- *
- * rastrillo's `position-try-fallbacks` still applies on top, so a menu
- * near the right edge of a narrow viewport still flips rather than
- * running off — which is why this is a change of base area and not of
- * `position-try-fallbacks` too. Restating the @supports condition
- * rather than nesting: the two must test the same thing, and an engine
- * that supports one half of it would otherwise get neither branch. */
-@supports (position-area: block-end) and (position-try-fallbacks: flip-block) {
- [data-menubar] [rst-dropdown-menu] {
- inset: auto;
- position-area: block-end span-inline-end;
- }
+/* An item that needs commands.js is rendered hidden until it can run.
+ * estilo's rows are display: flex, which is an author rule and beats the
+ * user agent's [hidden] { display: none } however weak its selector
+ * looks, so hidden is restated. */
+[data-menubar] [estilo-menu] > [hidden] {
+ display: none;
}
-/* An item is a row with its shortcut pushed to the far end, which is
- * what makes a menu readable as a table of keys as well as a list of
- * actions. The form wrapper around a scriptless POST is display:contents
- * so its button lays out as though the form were not there.
- *
- * :not([data-size-form]) is load-bearing, and its absence is what made
- * the Format menu ragged. A resize form is not a wrapper around one
- * menu item — it is a label, a number field and a button that read as
- * ONE control, and [data-size-form] lays them out in a flex row to say
- * so. This rule is (0,3,0) and that one is (0,1,0), so display:contents
- * won: the form stopped generating a box, its three children became
- * menu items in their own right, and the panel rendered "Column width"
- * and "px" on one line with a full-width "Set" under them. Both halves
- * looked correct in isolation. */
-[data-menubar] [rst-dropdown-menu] > form:not([data-size-form]) {
- display: contents;
-}
-
-[data-menubar] [rst-dropdown-menu] a,
-[data-menubar] [rst-dropdown-menu] button {
- align-items: baseline;
- display: flex;
- gap: var(--rst-sp-4);
- justify-content: space-between;
+/* The Format menu's two resize forms sit in the panel as rows: the same
+ * inline padding as estilo's items, so their labels line up with the
+ * item text above and below. */
+[data-menubar] [estilo-menu] > [data-size-form] {
+ padding: var(--rst-sp-1) var(--estilo-item-padding-inline, 0.65rem);
}
-/* A submenu summary is an item too — the same row, the same hover, plus
- * the one thing that distinguishes it: a chevron saying it opens rather
- * than does. rastrillo styles [rst-menu-group] > summary with padding
- * and a muted colour and stops there, so Alignment and Bold were the
- * same shape and only one of them expanded.
+/* ── The keyboard shortcuts dialog ──────────────────────────────────
*
- * The chevron is real markup, not a ::after character, so rastrillo's
- * own `details[open] > summary > [rst-caret]` rotates it on open and
- * the state is visible rather than remembered. */
-[data-menubar] [rst-dropdown-menu] [rst-menu-group] > summary {
- align-items: center;
- border-radius: var(--rst-radius-sm);
- display: flex;
- gap: var(--rst-sp-4);
- justify-content: space-between;
-}
-
-[data-menubar] [rst-dropdown-menu] [rst-menu-group] > summary:hover {
- background: var(--rst-accent-soft);
+ * Opened from the account menu's Help. A native modal <dialog>, so it is
+ * in the top layer above the bar; a float, so it takes estilo's float
+ * radius and shadow. */
+[data-shortcuts] {
+ inline-size: min(26rem, calc(100vw - 2rem));
+ padding: var(--rst-sp-4);
+ border: 1px solid var(--rst-line);
+ border-radius: var(--estilo-float-radius, 9px);
+ background: var(--rst-surface);
+ color: var(--rst-text);
+ box-shadow: var(--rst-shadow-pop);
}
-[data-menubar] kbd {
- color: var(--rst-text-faint);
- font-family: var(--rst-font);
- font-size: var(--rst-fs-xs);
- white-space: nowrap;
+[data-shortcuts]::backdrop {
+ background: color-mix(in oklab, var(--rst-text) 30%, transparent);
}
-[data-menubar] [data-sample] {
- color: var(--rst-text-faint);
- font-size: var(--rst-fs-xs);
+[data-shortcuts] h2 {
+ margin: 0 0 var(--rst-sp-3);
+ font-size: 1.125rem;
}
-[data-menubar] hr {
- border: 0;
- border-top: 1px solid var(--rst-line);
- margin: var(--rst-sp-1) 0;
-}
-
-/* The Help panel is the shortcut list itself rather than a link to a
- * page that does not exist, so it is a definition list and not a stack
- * of menu items. */
[data-shortcuts] dl {
+ align-items: baseline;
display: grid;
grid-template-columns: auto 1fr;
gap: var(--rst-sp-1) var(--rst-sp-4);
margin: 0;
- padding: var(--rst-sp-2);
}
[data-shortcuts] dt {
text-align: end;
}
+[data-shortcuts] dt kbd {
+ font-family: var(--rst-font);
+ font-size: var(--rst-fs-sm);
+ font-variant-numeric: tabular-nums;
+}
+
[data-shortcuts] dd {
color: var(--rst-text-muted);
font-size: var(--rst-fs-sm);
margin: 0;
}
+[data-shortcuts-foot] {
+ display: flex;
+ justify-content: flex-end;
+ margin-block-start: var(--rst-sp-4);
+}
+
/* ── The toolbar ─────────────────────────────────────────────────────
*
* `rst-btn="compact"` is not a rastrillo variant — the word appears in
@@ -369,11 +148,14 @@ form[data-doc-title]:not(:focus-within) button {
padding: var(--rst-sp-1) 0;
}
+/* Square (the suite's square rule, spec §11 and Paul's §13 answer 4):
+ * these buttons are borderless, so their hover and pressed fill is an
+ * in-flow highlight, not a bordered control. They were 6px. */
[data-tool] {
align-items: center;
background: transparent;
border: 0;
- border-radius: var(--rst-radius-sm);
+ border-radius: 0;
color: var(--rst-text-muted);
cursor: pointer;
display: inline-flex;
@@ -443,6 +225,12 @@ form[data-doc-title]:not(:focus-within) button {
}
}
+/* A picker's tooltip names the button; once its panel is open it only
+ * covers the panel's first row ("No fill"), so it steps aside. */
+[data-tool-menu][open] > [rst-tip]::after {
+ opacity: 0;
+}
+
[data-tool] [rst-caret] svg {
block-size: 0.75rem;
inline-size: 0.75rem;
@@ -641,11 +429,15 @@ form[data-doc-title]:not(:focus-within) button {
color: var(--rst-tone-warning-fg);
}
+/* isolation: the vendored grid's stylesheets stack with z-index values
+ * up to 10001. Contained here they compete only with each other, under
+ * the bar's 45 and the menus that float over the grid (spec §5.3). */
[data-sheet-grid] {
flex: 1;
min-height: 0;
overflow: auto;
background: var(--rst-bg);
+ isolation: isolate;
}
/* ── The vendored grid ───────────────────────────────────────────────
@@ -967,7 +759,13 @@ form[data-doc-title]:not(:focus-within) button {
text-decoration: none;
display: block;
min-block-size: 1.25rem;
- border-radius: var(--rst-radius-sm);
+ /* Square, and the whole cell: the link reaches out over the cell's own
+ * padding and pads itself back in, so its hover fills the cell edge to
+ * edge. It was a 6px rounded pill round the text inside a square cell
+ * — on an empty cell a small floating blob (the square rule, spec §11:
+ * an in-flow highlight is square). */
+ margin: calc(-1 * var(--rst-sp-1)) calc(-1 * var(--rst-sp-2));
+ padding: var(--rst-sp-1) var(--rst-sp-2);
}
[data-cell-table] td > a:hover,
@@ -1080,6 +878,13 @@ form[data-doc-title]:not(:focus-within) button {
display: none;
}
+/* A full-window overlay sits above the bar (spec §5.3): rastrillo's
+ * modal overlay is z-index 10, under the bar's 45, so without this the
+ * bar would paint over the top of the drop target. */
+[data-drop-page] {
+ z-index: 50;
+}
+
[data-drop-page] [rst-modal-panel] {
max-width: 24rem;
transition: border-color 120ms ease;
@@ -1387,6 +1192,33 @@ form[data-doc-title]:not(:focus-within) button {
}
}
+/* The share page's two lists: links and people. The same reset the
+ * sheet list needs — rastrillo's [rst-list] keeps the browser's 40px
+ * list indent — and two columns, so "Remove" and "Turn off" sit beside
+ * the row instead of dropping under its name: [rst-lrow] is a one-column
+ * grid until --rst-cols says otherwise. Below 800px rastrillo sets the
+ * columns directly, so the narrow rule restates them. */
+[data-share-list] {
+ list-style: none;
+ margin-block: var(--rst-sp-3) var(--rst-sp-4);
+ margin-inline: 0;
+ padding: 0;
+}
+
+[data-share-list] > [rst-lrow] {
+ --rst-cols: minmax(0, 1fr) auto;
+}
+
+@media (max-width: 800px) {
+ [data-share-list] > [rst-lrow] {
+ grid-template-columns: minmax(0, 1fr) auto;
+ }
+}
+
+[data-share-list] form {
+ margin: 0;
+}
+
[data-preview-slot] {
display: block;
block-size: 34px;
diff --git a/internal/sheets/templates/comments.html b/internal/sheets/templates/comments.html
index d912de7..056e5d4 100644
--- a/internal/sheets/templates/comments.html
+++ b/internal/sheets/templates/comments.html
@@ -1,15 +1,15 @@
{{define "content"}}
<header rst-page-header>
<div rst-page-header-titles>
-<h1>Comments — {{.Data.Sheet.Title}}</h1>
+<h1>Comments</h1>
<p rst-page-header-sub>Notes about particular cells. Open threads first.</p>
</div>
-<a rst-btn="ghost" href="/sheets/{{.Data.Sheet.ID}}">Back to the sheet</a>
+<a rst-btn="ghost" href="/sheets/{{.Data.Sheet.ID}}">Back to sheet</a>
</header>
-{{with .Data.Notice}}
-<div rst-callout><div rst-callout-body><p>{{.}}</p></div></div>
-{{end}}
+{{/* A fixed sentence chosen by code, in its tone (shared chrome spec §4);
+ role="alert" only for a submit that did not work. */}}
+{{with .Data.Notice}}{{template "callout" (dict "Body" . "Tone" $.Data.NoticeTone "Alert" (eq $.Data.NoticeTone "negative"))}}{{end}}
{{if .Data.MayComment}}
<form rst-form method="post" action="/sheets/{{.Data.Sheet.ID}}/comments">
diff --git a/internal/sheets/templates/error.html b/internal/sheets/templates/error.html
new file mode 100644
index 0000000..a684239
--- /dev/null
+++ b/internal/sheets/templates/error.html
@@ -0,0 +1,6 @@
+{{define "content"}}
+{{/* The error page (spec §3): estilo-error, inside the normal layout and
+ bar, so a stale link or a revoked share shows the viewer's own chrome
+ and a way back instead of plain "Not found." text. */}}
+{{template "estilo-error" .Data.Error}}
+{{end}}
diff --git a/internal/sheets/templates/history.html b/internal/sheets/templates/history.html
index 6a7ca3b..b023e0a 100644
--- a/internal/sheets/templates/history.html
+++ b/internal/sheets/templates/history.html
@@ -1,19 +1,19 @@
{{define "content"}}
<header rst-page-header>
<div rst-page-header-titles>
-<h1>History — {{.Data.Sheet.Title}}</h1>
+<h1>Version history</h1>
<p rst-page-header-sub>Every edit somebody typed, newest first.</p>
</div>
-<a rst-btn="ghost" href="/sheets/{{.Data.Sheet.ID}}">Back to the sheet</a>
+<a rst-btn="ghost" href="/sheets/{{.Data.Sheet.ID}}">Back to sheet</a>
</header>
-{{with .Data.Notice}}
-<div rst-callout><div rst-callout-body><p>{{.}}</p></div></div>
-{{end}}
+{{/* A fixed sentence chosen by code, in its tone (shared chrome spec §4);
+ role="alert" only for a submit that did not work. */}}
+{{with .Data.Notice}}{{template "callout" (dict "Body" . "Tone" $.Data.NoticeTone "Alert" (eq $.Data.NoticeTone "negative"))}}{{end}}
{{if .Data.MayWrite}}
<form method="post" action="/sheets/{{.Data.Sheet.ID}}/undo">
-<button rst-btn="primary" type="submit">Undo my last edit</button>
+<button rst-btn="primary" type="submit">Undo</button>
</form>
{{/* rst-field-help, not rst-help — see share.html and the guard. */}}
<p rst-field-help>Undo only ever touches your own edits, one cell at a time. If
diff --git a/internal/sheets/templates/index.html b/internal/sheets/templates/index.html
index 13fc274..48bd2dc 100644
--- a/internal/sheets/templates/index.html
+++ b/internal/sheets/templates/index.html
@@ -1,28 +1,19 @@
{{define "content"}}
<header rst-page-header>
<div rst-page-header-titles>
-<h1>Oficina Sheets</h1>
+<h1>Sheets</h1>
<p rst-page-header-sub>A spreadsheet whose contents are sealed at rest.</p>
</div>
</header>
-{{/* One state only. This page used to branch on .SignedIn, because a
- magic link landed here and a page that could not say who you are
- made a successful sign-in and a silently failed one look identical.
- That branch was a line naming you and a button to /sheets, written
- before /sheets existed. handleIndex redirects a signed-in visitor
- there now and auth lands them there directly, so this page is only
- ever rendered for somebody who is not signed in — and the layout's
- account menu is what names you, on every screen. */}}
-<p>You are not signed in. <a href="/signin">Sign in</a> — we email you a
-link instead of asking for a password.</p>
+{{/* One state only, and only for a standalone instance. handleIndex
+ redirects a signed-in visitor to the list and, with Home configured,
+ a signed-out one to /signin (Home #74, decision 2); this page is what
+ a signed-out visitor to an instance without Home sees. The bar's
+ corner carries the Sign in button, so the page does not repeat it as
+ a default-blue prose link. */}}
+<p>Sign in to open the sheets shared with you. There is no password: we
+email you a link instead.</p>
-<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>
+{{template "callout" (dict "Body" "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." "Tone" "info")}}
{{end}}
diff --git a/internal/sheets/templates/job.html b/internal/sheets/templates/job.html
index aaa5386..b1f9970 100644
--- a/internal/sheets/templates/job.html
+++ b/internal/sheets/templates/job.html
@@ -24,5 +24,5 @@ as soon as the import finishes.</p>
{{template "job-status" .Data.Status}}
-<p><a href="/sheets">Back to your sheets</a></p>
+<p><a href="/sheets">All sheets</a></p>
{{end}}
diff --git a/internal/sheets/templates/layout.html b/internal/sheets/templates/layout.html
index b45de68..ffe3073 100644
--- a/internal/sheets/templates/layout.html
+++ b/internal/sheets/templates/layout.html
@@ -1,135 +1,97 @@
{{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.
+{{/* layout — every page's document: the head, the estilo bar and <main>.
- It is ABSENT for "auto", and that is the mechanism, not an
- omission: theme.css declares every colour as light-dark(a, b) and
- its entire toggle is :root[data-theme="light"] and
- :root[data-theme="dark"]. With no attribute the browser resolves
- against the OS. Writing anything else here — a theme NAME, say —
- matches neither rule, and the switcher silently does nothing. */}}
-<html lang="en" dir="ltr"{{if .ThemeAttr}} data-theme="{{.ThemeAttr}}"{{end}}>
+ The bar is estilo's (chrome.Bar, built once per request in
+ chrome.go), in place of the rastrillo topbar markup this file used to
+ write out itself: that bar was 45px signed out and 51px signed in,
+ because the Create and Import buttons were taller than it, so signing
+ in moved the whole page. estilo's is 52px at every width, signed in
+ or out, with the account always last in the same corner.
+
+ data-theme is written into the first bytes of the response, from a
+ cookie read server-side (chrome.Appearance). AGENTS.md: light/dark
+ must resolve before first paint, because cell fills resolve against
+ the theme and a late preference repaints every coloured cell. It is
+ ABSENT for "system", and that is the mechanism, not an omission:
+ theme.css's whole toggle is :root[data-theme="light"] and
+ :root[data-theme="dark"], and with no attribute the browser resolves
+ against the OS.
+
+ The <title> is built here and only here, "{part} · {part} · Sheets",
+ so no handler can drift from the suite's separator (spec §10).
+
+ <meta charset> comes first, before anything that could be read as
+ text. The stylesheets are in the suite's fixed order: tokens, theme,
+ brand, then estilo's base, menu and chrome, then the app's own. */}}
+<html lang="en" dir="ltr"{{with .Frame.Theme}} data-theme="{{.}}"{{end}}>
<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{range .Frame.TitleParts}}{{.}} · {{end}}{{.Frame.Chrome.App.Name}}</title>
<link rel="icon" type="image/png" sizes="32x32" href="{{asset "static/brand/icon-32.png"}}">
<link rel="icon" type="image/svg+xml" href="{{asset "static/brand/icon.svg"}}">
<link rel="apple-touch-icon" sizes="180x180" href="{{asset "static/brand/apple-touch-icon.png"}}">
-<link rel="stylesheet" href="{{asset "static/brand/brand.css"}}">
-<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/brand/brand.css"}}">
+<link rel="stylesheet" href="{{asset "static/estilo/base.css"}}">
+<link rel="stylesheet" href="{{asset "static/estilo/menu.css"}}">
+<link rel="stylesheet" href="{{asset "static/estilo/chrome.css"}}">
<link rel="stylesheet" href="{{asset "static/sheets.css"}}">
+{{/* First of every script: while a modal dialog is open, key and
+ clipboard events stop at the window before any page listener hears
+ them (static/modal.js). */}}
+<script defer src="{{asset "static/modal.js"}}"></script>
<script defer src="{{asset "static/rastrillo.js"}}"></script>
+<script defer src="{{asset "static/estilo/chrome.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 estilo-brand href="/"><img estilo-brand-icon src="{{asset "static/brand/icon.svg"}}" alt="" width="28" height="28">Sheets</a>
-{{/* The open document's title, in the top bar beside the brand —
- spec §10.2's ruling ("Top bar is title, Share, avatar"), and it
- is what buys the grid page a row: Google spends a whole row on
- the title, and this app already has a bar to put it in.
-
- Static text, not an input. There is no rename route, so anything
- that looked editable would be a promise the app cannot keep.
- Rendered only on a page that supplies one, which is why it is a
- block rather than a field on PageData: no other screen has a
- document open. */}}
-{{block "doctitle" .}}{{end}}
-{{if .SignedIn}}
-{{/* The narrow-viewport disclosure, and without it there is no way to
- sign out on a phone.
-
- rastrillo's topbar sets [rst-shell-tail] { display: none } and
- reveals it from exactly two places: an [rst-shell-menu][open] that
- IMMEDIATELY PRECEDES the tail, and the 800px media query. An app
- that ships no menu therefore has no navigation and no account below
- 800px — not collapsed behind something, simply absent. So the
- position of this element is load-bearing: it is the tail's previous
- sibling because the framework's rule is a `+` combinator, and
- moving it anywhere else silently restores the bug.
-
- It deliberately does NOT join the account dropdown's "rst-menus"
- group. `name` on <details> makes an exclusive accordion, and the
- account dropdown lives INSIDE the tail this menu reveals — grouped,
- opening the account would close this one, collapse the tail, and
- take the dropdown down with it. They nest, so they must not be
- exclusive.
-
- Rendered only when signed in: signed out the tail holds an empty
- nav, and a control that discloses nothing is worse than no control.
- rastrillo hides it itself above 800px, so there is no media query
- of ours here. */}}
-<details rst-shell-menu>
-<summary>Menu</summary>
-</details>
-{{end}}
-<div rst-shell-tail>
-{{/* There is no nav here, and the empty landmark went with the link.
-
- It held one item, "Your sheets", pointing at /sheets — which is
- where the brand two elements to the left already goes: handleIndex
- 303s a signed-in visitor from / to /sheets. So the bar read
- "Sheets ... Your sheets" and both were the same destination under
- two names, which is a menu of one choice offered twice.
-
- The brand is the way back to the list. That is what a wordmark in
- the top-left is for, and it is one fewer thing to read. */}}
-{{/* A page's own top-bar actions, beside the account menu.
-
- Deliberately a block rather than a field on PageData, and
- deliberately empty by default: the sheet list fills it with Create
- and Import, and the grid page fills it with nothing, because the
- grid already carries a File menu holding both and a second Create
- in the bar above it would be the same verb twice on one screen.
-
- Inside the tail, so it collapses behind the narrow-viewport Menu
- disclosure along with the nav and the account rather than being
- the one control still floating in the bar at 360px. */}}
-{{block "topbaractions" .}}{{end}}
-{{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="/mode">
-<input type="hidden" name="return" value="{{.Path}}">
-<button rst-btn="ghost" type="submit" name="mode" value="light"{{if eq .Mode "light"}} aria-current="true"{{end}}>Light</button>
-<button rst-btn="ghost" type="submit" name="mode" value="dark"{{if eq .Mode "dark"}} aria-current="true"{{end}}>Dark</button>
-<button rst-btn="ghost" type="submit" name="mode" value="auto"{{if eq .Mode "auto"}} aria-current="true"{{end}}>System</button>
-</form>
-{{if .HomeLinkLabel}}<a href="/account/home">{{.HomeLinkLabel}}</a>{{end}}
-<form method="post" action="/signout">
-<button rst-btn="ghost" type="submit">Sign out</button>
-</form>
-</div>
-</details>
-{{end}}
-</div>
-</header>
+{{/* [estilo-shell] wraps every page. On the grid it is
+ estilo-shell="fill": exactly the viewport, the bar at its 52px and
+ the grid's <main> taking the rest (estilo chrome.css). That is why
+ the grid can fill the screen without this app subtracting a bar
+ height it would have to guess. The "main" block lets the grid page
+ supply its own <main>, without rst-page's padding and max width. */}}
+<div estilo-shell{{with .Frame.Shell}}="{{.}}"{{end}}>
+<a rst-skip href="#main">{{.Frame.Chrome.Labels.Skip}}</a>
+{{template "estilo-bar" .Frame.Chrome}}
{{block "main" .}}<main rst-page id="main">{{template "content" .}}</main>{{end}}
</div>
+{{if .Frame.Shortcuts}}{{template "shortcuts-dialog" .Frame.Keys}}{{end}}
</body>
</html>
{{end}}
+{{/* The keyboard shortcuts, opened from the account menu's Help
+ ("Keyboard shortcuts…"). It used to be the Help menu on the grid's
+ menu bar, a definition list inside a dropdown; Help moved into the
+ account menu with every other app's (spec §8.2), and a list of keys
+ is not a menu. A native <dialog>, so it sits in the top layer above
+ the bar (spec §5.3), and it opens with the platform's invoker
+ command, so no script is needed. The labels are this request's
+ platform's (menu.Shortcut): ⌘Z on a Mac, Ctrl+Z elsewhere. */}}
+{{define "shortcuts-dialog"}}<dialog id="sheets-shortcuts" data-shortcuts aria-labelledby="sheets-shortcuts-title">
+<form method="dialog">
+<h2 id="sheets-shortcuts-title">Keyboard shortcuts</h2>
+<dl>
+<dt><kbd>{{.undo}}</kbd></dt><dd>Undo your last edit</dd>
+<dt><kbd>{{.bold}}</kbd></dt><dd>Bold</dd>
+<dt><kbd>{{.italic}}</kbd></dt><dd>Italic</dd>
+<dt><kbd>{{.cut}}</kbd></dt><dd>Cut</dd>
+<dt><kbd>{{.copy}}</kbd></dt><dd>Copy</dd>
+<dt><kbd>{{.paste}}</kbd></dt><dd>Paste</dd>
+<dt><kbd>{{.filldown}}</kbd></dt><dd>Fill down</dd>
+<dt><kbd>{{.delete}}</kbd></dt><dd>Clear the selected cells</dd>
+<dt><kbd>{{.enter}}</kbd></dt><dd>Commit and move down</dd>
+<dt><kbd>{{.tab}}</kbd></dt><dd>Commit and move right</dd>
+<dt><kbd>{{.esc}}</kbd></dt><dd>Abandon the edit</dd>
+</dl>
+<div data-shortcuts-foot><button rst-btn="primary" type="submit" autofocus>Done</button></div>
+</form>
+</dialog>{{end}}
+
{{define "contrast-notice"}}
{{if .}}
{{/* Spec §11.1: a retained fill can be unreadable, and that is
@@ -137,8 +99,11 @@
correcting it would lie about the document — so the obligation
that follows is to say so and leave the decision to the person.
This is that, and it is deliberately a notice rather than a
- change to any cell. */}}
-<div rst-callout="warn">
+ change to any cell. A warning (the shared chrome spec's §4): it
+ used to say rst-callout="warn", which is no rastrillo tone, so it
+ rendered as a neutral grey note. */}}
+<div rst-callout rst-tone="warning">
+<span rst-callout-ic aria-hidden="true">{{icon "alert-triangle"}}</span>
<div rst-callout-body>
<p><strong>Imported colours below the contrast floor.</strong>
These cells came from a file pairing a fill and a text colour under
diff --git a/internal/sheets/templates/link.html b/internal/sheets/templates/link.html
index 4f9090e..8eb78a0 100644
--- a/internal/sheets/templates/link.html
+++ b/internal/sheets/templates/link.html
@@ -7,13 +7,7 @@
</header>
{{if .Data.CanComment}}
-<div rst-callout>
-<div rst-callout-body>
-<p>You can comment on this sheet once you sign in. Reading needs no
-account; anything you write is attributed, and that is what an account
-is for.</p>
-</div>
-</div>
+{{template "callout" (dict "Body" "You can comment on this sheet once you sign in. Reading needs no account; anything you write is attributed, and that is what an account is for." "Tone" "info")}}
{{end}}
<div style="overflow-x:auto">
diff --git a/internal/sheets/templates/share.html b/internal/sheets/templates/share.html
index 53733c5..4656d23 100644
--- a/internal/sheets/templates/share.html
+++ b/internal/sheets/templates/share.html
@@ -1,15 +1,15 @@
{{define "content"}}
<header rst-page-header>
<div rst-page-header-titles>
-<h1>Sharing “{{.Data.Sheet.Title}}”</h1>
+<h1>Share</h1>
<p rst-page-header-sub>Who can open this sheet, and what they can do with it.</p>
</div>
-<a rst-btn="ghost" href="/sheets/{{.Data.Sheet.ID}}">Back to the sheet</a>
+<a rst-btn="ghost" href="/sheets/{{.Data.Sheet.ID}}">Back to sheet</a>
</header>
-{{with .Data.Notice}}
-<div rst-callout><div rst-callout-body><p>{{.}}</p></div></div>
-{{end}}
+{{/* A fixed sentence chosen by code, in its tone (shared chrome spec §4);
+ role="alert" only for a submit that did not work. */}}
+{{with .Data.Notice}}{{template "callout" (dict "Body" . "Tone" $.Data.NoticeTone "Alert" (eq $.Data.NoticeTone "negative"))}}{{end}}
<form rst-form method="post" action="/sheets/{{.Data.Sheet.ID}}/share">
<div rst-field-row>
@@ -32,16 +32,13 @@
directory carrying the granting team's name. The behaviour is kept
and the obligation is to disclose it in words, here, rather than
leave it an emergent property nobody decided. */}}
-<div rst-callout>
-<div rst-callout-body>
-<p>Sharing with somebody outside your team lets them see your team's
-name, because this sheet then appears in their list alongside their own.
-They see nothing else about the team, and only the sheets you share.</p>
-</div>
-</div>
+{{template "callout" (dict "Body" "Sharing with somebody outside your team lets them see your team’s name, because this sheet then appears in their list alongside their own. They see nothing else about the team, and only the sheets you share." "Tone" "info")}}
{{with .Data.NewLink}}
-<div rst-callout="warn">
+{{/* A warning, the tone rastrillo defines: rst-callout="warn" matched
+ no rule, so this rendered as an ordinary grey note. */}}
+<div rst-callout rst-tone="warning">
+<span rst-callout-ic aria-hidden="true">{{icon "alert-triangle"}}</span>
<div rst-callout-body>
<p><strong>Your link.</strong> Copy it now: only its fingerprint is
stored, so this page cannot show it to you again.</p>
@@ -71,7 +68,7 @@ people you send it to. A link can never edit.</p>
</form>
{{if .Data.Links}}
-<ul rst-list>
+<ul rst-list data-share-list>
{{$sheet := .Data.Sheet}}
{{range .Data.Links}}
<li rst-lrow>
@@ -90,7 +87,7 @@ people you send it to. A link can never edit.</p>
<h2>Who has access</h2>
{{if .Data.People}}
-<ul rst-list>
+<ul rst-list data-share-list>
{{$sheet := .Data.Sheet}}
{{range .Data.People}}
<li rst-lrow>
diff --git a/internal/sheets/templates/sheet.html b/internal/sheets/templates/sheet.html
index 8de06d1..e164468 100644
--- a/internal/sheets/templates/sheet.html
+++ b/internal/sheets/templates/sheet.html
@@ -24,39 +24,14 @@
<script defer src="{{asset "static/commands.js"}}"></script>
{{end}}
-{{define "doctitle"}}
-{{/* The open document's title, and — for anyone who may edit it — the
- rename.
-
- This was static text, under a comment saying a rename route did
- not exist and that anything looking editable would be a promise
- the app could not keep. The route exists now, and it had to: the
- sheet list's Create button stopped asking for a title, so every
- sheet is born "Untitled sheet" and this is the only place the
- name is ever fixed. See handlers_rename.go.
-
- An inline form, not a popover. A <details> here would join
- rastrillo's "rst-menus" exclusive group along with the File menu
- and the account dropdown — opening one would close the others,
- and a rename that shuts the menu you opened it from is the
- nested-accordion trap TestNoNestedExclusiveDetails exists to
- catch. An input has no such problem.
-
- Scriptless by construction: it is a real form with a real submit.
- The button is visually hidden until the input has focus, so the
- bar stays a title bar until somebody means to edit it, and Enter
- submits from the input either way. */}}
-{{if .Data.MayWrite}}
-<form data-doc-title method="post" action="/sheets/{{.Data.Sheet.ID}}/rename">
-<input data-doc-title-input name="title" value="{{.Data.Sheet.Title}}"
- aria-label="Sheet title" autocomplete="off" spellcheck="false">
-<button rst-btn="ghost" type="submit">Rename</button>
-</form>
-{{else}}
-<span data-doc-title><span data-doc-title-text
- title="{{.Data.Sheet.Title}}">{{.Data.Sheet.Title}}</span></span>
-{{end}}
-{{end}}
+{{/* The open sheet's title, and for anyone who may edit it the rename,
+ are the bar's (estilo's editable title slot, chrome.TitleEdit, built
+ in handleSheetPage). It is still a real form posting to
+ /sheets/{id}/rename, so Enter renames with scripting off, and the
+ Rename button is visually hidden until the field has focus. Escape
+ restores the title and leaves the field (estilo chrome.js), which
+ fixes the sticky focus look the audit found. A reader sees the
+ title read-only. */}}
{{define "main"}}
{{/* The grid gets its own <main>, without rst-page's padding and max
@@ -75,7 +50,11 @@
was simply not on this screen, with nothing anywhere
admitting a window existed. */}}
{{if .Data.Truncated}}data-window-truncated="1"{{end}}
- {{if .Data.MayWrite}}data-may-write="1"{{end}}>
+ {{if .Data.MayWrite}}data-may-write="1"{{end}}
+ {{/* The chords clipboard.js names when a browser refuses a menu
+ copy, cut or paste ("Press ⌘C instead"), in this request's
+ platform's spelling, so its status line agrees with the menu. */}}
+ data-key-copy="{{.Frame.Keys.copy}}" data-key-cut="{{.Frame.Keys.cut}}" data-key-paste="{{.Frame.Keys.paste}}">
{{template "content" .}}
</main>
{{end}}
@@ -108,96 +87,95 @@
budget. */}}
<div data-chrome>
-<nav data-menubar aria-label="Sheet menu">
-
-{{/* Every top-level menu shares rastrillo's "rst-menus" group, which
- makes them an exclusive accordion: opening File closes Edit, and
- closes the account menu in the topbar too, which is what a person
- expects. rastrillo.js already handles click-outside and Escape.
-
- A SUBMENU MUST NEVER CARRY name=. It is nested inside a grouped
- <details>; grouped, opening it closes the parent it lives in and
- takes itself down with it. TestNoNestedExclusiveDetails pins
- this. */}}
-
-<details rst-dropdown name="rst-menus">
-<summary>File</summary>
-<div rst-dropdown-menu>
-<form method="post" action="/sheets"><button type="submit">New sheet</button></form>
-{{/* A page, not a file input in this dropdown. A hidden input behind a
- menu-shaped label never submits itself, so the menu item would
- have needed script to import anything at all — see
- handleImportPage. */}}
-<a href="/sheets/import">Import a spreadsheet…</a>
-<a href="/sheets/{{.Data.Sheet.ID}}/export.xlsx">Download as .xlsx</a>
+<nav estilo-menubar data-menubar aria-label="Sheet menu">
+
+{{/* estilo's menu bar ([estilo-menubar], estilo menu.css): square titles
+ in the page flow, 9px floating panels with inset rows, a shortcut
+ column, and the platform's own labels (⌘Z on a Mac, Ctrl+Z
+ elsewhere) from estilo's menu.Shortcut, formatted server-side.
+
+ Every top-level menu is in the one group, name="estilo-menus", with
+ the bar's Menu, switcher and account: opening File closes the
+ account menu and the other way round, which is the behaviour this
+ app chose on purpose and TestTheMenusShareOneGroup pins. estilo's
+ chrome.js closes the group on Escape and an outside click.
+
+ There are no submenus. A <details> nested inside a grouped one
+ closes its parent when it opens (TestNoNestedExclusiveDetails), and
+ inline accordions made Format a panel that scrolled with its
+ swatches cut off. Format lost Number, Fill colour and Text colour,
+ which the toolbar beside it already carries; Insert's functions
+ are rows under a heading. Hidden items are hidden until
+ commands.js can run them: an item appears only if it works. */}}
+
+<details estilo-menubar-menu name="estilo-menus">
+<summary estilo-menubar-title>File</summary>
+<div estilo-menu>
+<form method="post" action="/sheets"><button estilo-menu-item type="submit"><span estilo-menu-label>New sheet</span></button></form>
+{{/* A page, not a file input in this menu. A hidden input behind a
+ menu-shaped label never submits itself, so the item would have
+ needed script to import anything at all — see handleImportPage. */}}
+<a estilo-menu-item href="/sheets/import"><span estilo-menu-label>Import a spreadsheet…</span></a>
+<a estilo-menu-item href="/sheets/{{.Data.Sheet.ID}}/export.xlsx"><span estilo-menu-label>Download as .xlsx</span></a>
<hr>
-<a href="/sheets/{{.Data.Sheet.ID}}/table">Open as table</a>
-<a href="/sheets/{{.Data.Sheet.ID}}/history">Version history</a>
-{{if .Data.MayShare}}<a href="/sheets/{{.Data.Sheet.ID}}/share">Share…</a>{{end}}
+<a estilo-menu-item href="/sheets/{{.Data.Sheet.ID}}/table"><span estilo-menu-label>Open as table</span></a>
+<a estilo-menu-item href="/sheets/{{.Data.Sheet.ID}}/history"><span estilo-menu-label>Version history</span></a>
+{{if .Data.MayShare}}<a estilo-menu-item href="/sheets/{{.Data.Sheet.ID}}/share"><span estilo-menu-label>Share…</span></a>{{end}}
</div>
</details>
{{if .Data.MayWrite}}
-<details rst-dropdown name="rst-menus">
-<summary>Edit</summary>
-<div rst-dropdown-menu>
+<details estilo-menubar-menu name="estilo-menus">
+<summary estilo-menubar-title>Edit</summary>
+<div estilo-menu>
{{/* Undo is a form, not a button, so it works with scripting off —
it posts to the same route the keyboard shortcut fetches, so
there is one undo and not two that can disagree. */}}
-<form method="post" action="/sheets/{{.Data.Sheet.ID}}/undo">
-<button type="submit">Undo my last edit <kbd>Ctrl Z</kbd></button>
-</form>
-<hr>
-<button type="button" data-cmd="cut" hidden>Cut <kbd>Ctrl X</kbd></button>
-<button type="button" data-cmd="copy" hidden>Copy <kbd>Ctrl C</kbd></button>
+<form method="post" action="/sheets/{{.Data.Sheet.ID}}/undo"><button estilo-menu-item type="submit"><span estilo-menu-label>Undo</span><kbd estilo-menu-key>{{.Frame.Keys.undo}}</kbd></button></form>
+{{/* The separators around the script-only groups are script-only too:
+ with scripting off Undo stands alone, not above two empty rules. */}}
+<hr data-needs-js hidden>
+<button estilo-menu-item type="button" data-cmd="cut" hidden><span estilo-menu-label>Cut</span><kbd estilo-menu-key>{{.Frame.Keys.cut}}</kbd></button>
+<button estilo-menu-item type="button" data-cmd="copy" hidden><span estilo-menu-label>Copy</span><kbd estilo-menu-key>{{.Frame.Keys.copy}}</kbd></button>
{{/* Paste is here despite the browser being allowed to refuse a menu
read of the clipboard. It is not a dead control: clipboard.js
asks, and on a refusal says so in the status line below the grid
— which is more than the app could do for a person who does not
- know Ctrl+V is the only way. The keyboard path needs no
+ know the keyboard is the only way. The keyboard path needs no
permission at all, because a paste EVENT carries its own data. */}}
-<button type="button" data-cmd="paste" hidden>Paste <kbd>Ctrl V</kbd></button>
-<button type="button" data-cmd="clear" hidden>Clear contents <kbd>Delete</kbd></button>
-<hr>
+<button estilo-menu-item type="button" data-cmd="paste" hidden><span estilo-menu-label>Paste</span><kbd estilo-menu-key>{{.Frame.Keys.paste}}</kbd></button>
+<button estilo-menu-item type="button" data-cmd="clear" hidden><span estilo-menu-label>Clear contents</span><kbd estilo-menu-key>{{.Frame.Keys.delete}}</kbd></button>
+<hr data-needs-js hidden>
{{/* Fill down and fill right are the menu equivalents of the grid's
drag handle — AGENTS.md requires one for every drag-only
- affordance, and a drag handle is unreachable by keyboard and
- awkward on a touch screen.
-
- Ctrl+D only. Ctrl+R is reload in every browser, so fill right is
- deliberately shortcut-free rather than fighting the browser for
- a key people rely on. */}}
-<button type="button" data-cmd="fill-down" hidden>Fill down <kbd>Ctrl D</kbd></button>
-<button type="button" data-cmd="fill-right" hidden>Fill right</button>
-<button type="button" data-cmd="select-all" hidden>Select all <kbd>Ctrl A</kbd></button>
+ affordance. Ctrl+R is reload in every browser, so fill right is
+ deliberately shortcut-free. */}}
+<button estilo-menu-item type="button" data-cmd="fill-down" hidden><span estilo-menu-label>Fill down</span><kbd estilo-menu-key>{{.Frame.Keys.filldown}}</kbd></button>
+<button estilo-menu-item type="button" data-cmd="fill-right" hidden><span estilo-menu-label>Fill right</span></button>
+<button estilo-menu-item type="button" data-cmd="select-all" hidden><span estilo-menu-label>Select all</span><kbd estilo-menu-key>{{.Frame.Keys.selectall}}</kbd></button>
</div>
</details>
{{end}}
{{if or .Data.MayWrite .Data.MayComment}}
-<details rst-dropdown name="rst-menus">
-<summary>Insert</summary>
-<div rst-dropdown-menu>
+<details estilo-menubar-menu name="estilo-menus">
+<summary estilo-menubar-title>Insert</summary>
+<div estilo-menu>
{{if .Data.MayWrite}}
-{{/* No name= — see the note above the File menu. */}}
-<details rst-menu-group hidden data-needs-js>
-<summary>Function<span rst-caret aria-hidden="true">{{icon "chevron-down"}}</span></summary>
-<div>
-{{/* Exactly the aggregates the engine registers and that a
- one-click insert can guess the arguments for. VLOOKUP, INDEX
- and MATCH are registered too and are deliberately absent:
- nothing here can guess their second and third arguments, and
- inserting =VLOOKUP() for somebody to fill in is a worse
- starting point than an empty cell. */}}
-<button type="button" data-cmd="fn" data-fn="SUM">SUM</button>
-<button type="button" data-cmd="fn" data-fn="AVERAGE">AVERAGE</button>
-<button type="button" data-cmd="fn" data-fn="COUNT">COUNT</button>
-<button type="button" data-cmd="fn" data-fn="MIN">MIN</button>
-<button type="button" data-cmd="fn" data-fn="MAX">MAX</button>
-</div>
-</details>
+{{/* Exactly the aggregates the engine registers and that a one-click
+ insert can guess the arguments for. VLOOKUP, INDEX and MATCH are
+ registered too and are deliberately absent: nothing here can guess
+ their second and third arguments. Rows under a heading, not a
+ submenu, and hidden until commands.js can insert them. */}}
+<p estilo-menu-heading data-needs-js hidden>Function</p>
+<button estilo-menu-item type="button" data-cmd="fn" data-fn="SUM" hidden><span estilo-menu-label>SUM</span></button>
+<button estilo-menu-item type="button" data-cmd="fn" data-fn="AVERAGE" hidden><span estilo-menu-label>AVERAGE</span></button>
+<button estilo-menu-item type="button" data-cmd="fn" data-fn="COUNT" hidden><span estilo-menu-label>COUNT</span></button>
+<button estilo-menu-item type="button" data-cmd="fn" data-fn="MIN" hidden><span estilo-menu-label>MIN</span></button>
+<button estilo-menu-item type="button" data-cmd="fn" data-fn="MAX" hidden><span estilo-menu-label>MAX</span></button>
+{{if .Data.MayComment}}<hr data-needs-js hidden>{{end}}
{{end}}
-{{if .Data.MayComment}}<a href="/sheets/{{.Data.Sheet.ID}}/comments">Comment…</a>{{end}}
+{{if .Data.MayComment}}<a estilo-menu-item href="/sheets/{{.Data.Sheet.ID}}/comments"><span estilo-menu-label>Comment…</span></a>{{end}}
</div>
</details>
{{end}}
@@ -206,64 +184,22 @@
{{/* The Format menu is entirely script-driven, so the whole menu is
hidden until commands.js reveals it. A menu of items that do
nothing is worse than no menu. */}}
-<details rst-dropdown name="rst-menus" hidden data-needs-js>
-<summary>Format</summary>
-<div rst-dropdown-menu>
-<button type="button" data-cmd="bold">Bold <kbd>Ctrl B</kbd></button>
-<button type="button" data-cmd="italic">Italic <kbd>Ctrl I</kbd></button>
+<details estilo-menubar-menu name="estilo-menus" hidden data-needs-js>
+<summary estilo-menubar-title>Format</summary>
+<div estilo-menu>
+<button estilo-menu-item type="button" data-cmd="bold"><span estilo-menu-label>Bold</span><kbd estilo-menu-key>{{.Frame.Keys.bold}}</kbd></button>
+<button estilo-menu-item type="button" data-cmd="italic"><span estilo-menu-label>Italic</span><kbd estilo-menu-key>{{.Frame.Keys.italic}}</kbd></button>
<hr>
-<details rst-menu-group>
-<summary>Alignment<span rst-caret aria-hidden="true">{{icon "chevron-down"}}</span></summary>
-<div>
-<button type="button" data-cmd="align" data-value="left">Left</button>
-<button type="button" data-cmd="align" data-value="center">Centre</button>
-<button type="button" data-cmd="align" data-value="right">Right</button>
-</div>
-</details>
-<details rst-menu-group>
-<summary>Number<span rst-caret aria-hidden="true">{{icon "chevron-down"}}</span></summary>
-<div>
-{{/* Ranged over the list the write endpoint validates against, so a
- button the UI renders is a pattern the route accepts — by
- construction, not by remembering to edit both. */}}
-{{range .Data.NumberFormats}}
-<button type="button" data-cmd="numfmt" data-value="{{.Code}}">{{.Label}}{{if .Sample}} <span data-sample>{{.Sample}}</span>{{end}}</button>
-{{end}}
-</div>
-</details>
-<details rst-menu-group>
-<summary>Fill colour<span rst-caret aria-hidden="true">{{icon "chevron-down"}}</span></summary>
-<div data-swatches>
-<button type="button" data-cmd="fill" data-value="none">No fill</button>
-{{range .Data.FillSwatches}}
-<button type="button" data-cmd="fill" data-value="{{.Name}}" aria-label="Fill {{.Label}}"
- data-swatch style="--sheet-h:{{.H}};--sheet-c:{{.C}}">{{.Label}}</button>
-{{end}}
-</div>
-</details>
-<details rst-menu-group>
-<summary>Text colour<span rst-caret aria-hidden="true">{{icon "chevron-down"}}</span></summary>
-<div data-swatches>
-<button type="button" data-cmd="ink" data-value="none">Default</button>
-{{range .Data.InkSwatches}}
-<button type="button" data-cmd="ink" data-value="{{.Name}}" aria-label="Text {{.Label}}"
- data-swatch data-ink-swatch style="--sheet-kh:{{.H}};--sheet-kc:{{.C}}">{{.Label}}</button>
-{{end}}
-</div>
-</details>
+<button estilo-menu-item type="button" data-cmd="align" data-value="left"><span estilo-menu-label>Align left</span></button>
+<button estilo-menu-item type="button" data-cmd="align" data-value="center"><span estilo-menu-label>Align centre</span></button>
+<button estilo-menu-item type="button" data-cmd="align" data-value="right"><span estilo-menu-label>Align right</span></button>
<hr>
{{/* The menu equivalent of dragging a column edge (AGENTS.md: every
- drag-only affordance has one). A drag is unreachable by keyboard
- and awkward on a touch screen, and the size it produces is the
- one thing on this menu you cannot express as a choice from a
- list — so it is a number field rather than a set of buttons.
-
- Real forms rather than a prompt(): a prompt is not styleable, not
- translatable, and is blocked outright in some embedding contexts.
- They post through commands.js because the size route takes JSON;
- the scriptless path to a resize is named in the spec's §14 with
- the rest of the table view's formatting debt. */}}
-<button type="button" data-cmd="autofit-columns">Auto-fit columns</button>
+ drag-only affordance has one). A number field rather than a set of
+ buttons, because the size a drag produces is the one thing here you
+ cannot pick from a list. Real forms rather than a prompt(); they
+ post through commands.js because the size route takes JSON. */}}
+<button estilo-menu-item type="button" data-cmd="autofit-columns"><span estilo-menu-label>Auto-fit columns</span></button>
<form data-size-form data-axis="col">
<label for="colw">Column width</label>
<input rst-input id="colw" name="size" type="number" min="16" max="2000" step="1" placeholder="px">
@@ -275,52 +211,33 @@
<button rst-btn="ghost" type="submit">Set</button>
</form>
<hr>
-<button type="button" data-cmd="clear-format">Clear formatting</button>
+<button estilo-menu-item type="button" data-cmd="clear-format"><span estilo-menu-label>Clear formatting</span></button>
</div>
</details>
{{end}}
-<details rst-dropdown name="rst-menus">
-<summary>Data</summary>
-<div rst-dropdown-menu>
+<details estilo-menubar-menu name="estilo-menus">
+<summary estilo-menubar-title>Data</summary>
+<div estilo-menu>
{{/* Both items lead to the table view, and the labels say so. The
grid does not apply a saved sort or filter yet — snapshot never
reads the ViewSpec, only the table does — and a "Sort A→Z" here
that silently moved you to another page would be worse than a
link that admits where it goes. */}}
-<a href="/sheets/{{.Data.Sheet.ID}}/table">Sort and filter, in the table view…</a>
+<a estilo-menu-item href="/sheets/{{.Data.Sheet.ID}}/table"><span estilo-menu-label>Sort and filter, in the table view…</span></a>
{{if .Data.MayWrite}}
<form method="post" action="/sheets/{{.Data.Sheet.ID}}/view">
<input type="hidden" name="sortCol" value="-1">
<input type="hidden" name="filterCol" value="-1">
-<button type="submit">Clear sort and filter</button>
+<button estilo-menu-item type="submit"><span estilo-menu-label>Clear sort and filter</span></button>
</form>
{{end}}
</div>
</details>
-<details rst-dropdown name="rst-menus">
-<summary>Help</summary>
-{{/* The panel IS the shortcut list rather than a link to a page that
- does not exist. It needs no route and no script, and a person
- looking for "what are the keys" finds them in the place they
- looked rather than one navigation away. */}}
-<div rst-dropdown-menu data-shortcuts>
-<dl>
-<dt><kbd>Ctrl Z</kbd></dt><dd>Undo my last edit</dd>
-<dt><kbd>Ctrl B</kbd></dt><dd>Bold</dd>
-<dt><kbd>Ctrl I</kbd></dt><dd>Italic</dd>
-<dt><kbd>Ctrl X</kbd></dt><dd>Cut</dd>
-<dt><kbd>Ctrl C</kbd></dt><dd>Copy</dd>
-<dt><kbd>Ctrl V</kbd></dt><dd>Paste</dd>
-<dt><kbd>Ctrl D</kbd></dt><dd>Fill down</dd>
-<dt><kbd>Delete</kbd></dt><dd>Clear the selected cells</dd>
-<dt><kbd>Enter</kbd></dt><dd>Commit and move down</dd>
-<dt><kbd>Tab</kbd></dt><dd>Commit and move right</dd>
-<dt><kbd>Esc</kbd></dt><dd>Abandon the edit</dd>
-</dl>
-</div>
-</details>
+{{/* No Help menu here. The shortcut list moved into the account menu's
+ Help, as "Keyboard shortcuts…" opening a dialog, where every app's
+ help lives (spec §8.2). */}}
</nav>
@@ -333,7 +250,7 @@
deliberately, so the two apps behave alike. */}}
<div data-toolbar role="toolbar" aria-label="Formatting" aria-controls="main" hidden data-needs-js>
-<button type="button" data-tool rst-tip data-cmd="undo" aria-label="Undo" data-tip="Undo (Ctrl+Z)">{{template "glyph-undo"}}</button>
+<button type="button" data-tool rst-tip data-cmd="undo" aria-label="Undo" data-tip="Undo ({{.Frame.Keys.undo}})">{{template "glyph-undo"}}</button>
<span data-tool-rule aria-hidden="true"></span>
@@ -348,8 +265,8 @@
<span data-tool-rule aria-hidden="true"></span>
-<button type="button" data-tool rst-tip data-cmd="bold" aria-pressed="false" aria-label="Bold" data-tip="Bold (Ctrl+B)"><b>B</b></button>
-<button type="button" data-tool rst-tip data-cmd="italic" aria-pressed="false" aria-label="Italic" data-tip="Italic (Ctrl+I)"><i>I</i></button>
+<button type="button" data-tool rst-tip data-cmd="bold" aria-pressed="false" aria-label="Bold" data-tip="Bold ({{.Frame.Keys.bold}})"><b>B</b></button>
+<button type="button" data-tool rst-tip data-cmd="italic" aria-pressed="false" aria-label="Italic" data-tip="Italic ({{.Frame.Keys.italic}})"><i>I</i></button>
<span data-tool-rule aria-hidden="true"></span>
@@ -407,7 +324,8 @@
script-required parts, and everything else as scriptless. A visitor
with scripting off should land somewhere useful rather than on an
empty div, so this is a link and not an apology. */}}
-<div rst-callout>
+<div rst-callout rst-tone="info">
+<span rst-callout-ic aria-hidden="true">{{icon "info"}}</span>
<div rst-callout-body>
<p>The grid needs JavaScript. Everything it does, the
<a href="/sheets/{{.Data.Sheet.ID}}/table">table view</a> also does —
diff --git a/internal/sheets/templates/sheets.html b/internal/sheets/templates/sheets.html
index 4844b61..c6881b6 100644
--- a/internal/sheets/templates/sheets.html
+++ b/internal/sheets/templates/sheets.html
@@ -24,29 +24,11 @@
<script defer src="{{asset "static/drop.js"}}"></script>
{{end}}
-{{define "topbaractions"}}
-{{if .SignedIn}}
-{{/* The wrapper is what carries the auto margin that puts these
- against the account menu — see sheets.css. It lives here rather
- than in layout.html so that a page which fills this block with
- nothing contributes no element at all, and the grid's bar is
- exactly what it was. */}}
-<div data-topbar-actions>
-{{/* Import first, Create last: Create is the primary action and sits
- closest to the account menu, which is the corner the eye returns
- to. A link rather than a form, because it opens a page rather
- than changing anything. */}}
-<a rst-btn href="/sheets/import">Import</a>
-{{/* No title field, and so no dialog and no second step: the button
- IS the create. handleSheetCreate defaults an empty title to
- "Untitled sheet" and 303s into the new sheet, so a click lands
- you in a grid you can type into. */}}
-<form method="post" action="/sheets">
-<button rst-btn="primary" type="submit">Create</button>
-</form>
-</div>
-{{end}}
-{{end}}
+{{/* Import and Create are the bar's page actions (handleSheetList builds
+ them as estilo chrome.Actions): buttons beside the account at wide
+ widths, rows in the Menu sheet below 800px. Create is a POST in both
+ places. They used to be an extra block in the rastrillo tail, whose
+ buttons were taller than the bar and grew it from 45px to 51px. */}}
{{define "content"}}
<header rst-page-header>
@@ -96,7 +78,6 @@ already have.</p>
</div>
{{end}}
-{{if .SignedIn}}
{{/* The drop target for the whole page.
This screen used to carry a dashed box you could drop a file on,
@@ -158,4 +139,3 @@ first worksheet is imported.</p>
</div>
</form>
{{end}}
-{{end}}
diff --git a/internal/sheets/templates/signed_out.html b/internal/sheets/templates/signed_out.html
new file mode 100644
index 0000000..18393de
--- /dev/null
+++ b/internal/sheets/templates/signed_out.html
@@ -0,0 +1 @@
+{{define "content"}}{{template "estilo-signed-out" .Data}}{{end}}
diff --git a/internal/sheets/templates/signin.html b/internal/sheets/templates/signin.html
index 8b4fce6..1ea067a 100644
--- a/internal/sheets/templates/signin.html
+++ b/internal/sheets/templates/signin.html
@@ -1,44 +1,9 @@
{{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>
-{{/* rst-field-help, not rst-help. rst-help is a 28x28 icon BADGE —
- inline-flex, a fixed width and height, a border and a radius — for
- the little "?" beside a label. Given a sentence it keeps the 28px
- box and lets the text overflow it, which rendered this paragraph
- one or two words per line with the badge's border struck through
- the middle of it, and pushed it off the left edge of the viewport
- on a phone. rst-field-help is the prose one: block, muted, sized
- down, spaced off the control above it.
-
- It lives inside the field, under the input, because that is what
- the token is for and where guidance about a control belongs. */}}
-<p rst-field-help>We email you a link instead of asking for a password.
-It signs you in once and then stops working.</p>
-</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>
+{{/* estilo's sign-in page, or the sent state that replaces the form
+ (estilo-signin, estilo-signin-sent; spec §9.2 and §9.3). One narrow,
+ left-aligned column capped at 26rem, which fixes the old primary
+ button stranded at the end of a 704px form. Every message comes from
+ signinError's fixed set — there is no path from the URL into this
+ page. */}}
+{{with .Data.Sent}}{{template "estilo-signin-sent" .}}{{else}}{{template "estilo-signin" .Data.Form}}{{end}}
{{end}}
diff --git a/internal/sheets/templates/table.html b/internal/sheets/templates/table.html
index 5f9e334..d8392ae 100644
--- a/internal/sheets/templates/table.html
+++ b/internal/sheets/templates/table.html
@@ -1,7 +1,9 @@
{{define "content"}}
<header rst-page-header>
<div rst-page-header-titles>
-<h1>{{.Data.Sheet.Title}}</h1>
+{{/* The page's name, as in its <title> ("Table view · {sheet} ·
+ Sheets"); the sheet's own title is in the bar beside the brand. */}}
+<h1>Table view</h1>
{{/* Was "rows {{.Data.Page}} of {{.Data.Pages}} pages", which spliced a
row count and a page count into one phrase that counted neither.
The trailing "with no JavaScript at all" went with it: this view is
@@ -10,9 +12,11 @@
<p rst-page-header-sub>Page {{.Data.Page}} of {{.Data.Pages}}.{{if .Data.MayWrite}}
Every cell here is editable.{{end}}</p>
</div>
-<a rst-btn="ghost" href="/sheets/{{.Data.Sheet.ID}}/history">History</a>
+{{/* One name per destination: "Version history" as in the File menu,
+ "Back to sheet" for the grid everywhere, "All sheets" for the list. */}}
+<a rst-btn="ghost" href="/sheets/{{.Data.Sheet.ID}}/history">Version history</a>
<a rst-btn="ghost" href="/sheets/{{.Data.Sheet.ID}}/comments">Comments</a>
-<a rst-btn="primary" href="/sheets/{{.Data.Sheet.ID}}">Open the grid</a>
+<a rst-btn="primary" href="/sheets/{{.Data.Sheet.ID}}">Back to sheet</a>
</header>
{{/* Sort and filter reorder what is SHOWN and never what is stored:
diff --git a/internal/sheets/theme.go b/internal/sheets/theme.go
deleted file mode 100644
index b7b6030..0000000
--- a/internal/sheets/theme.go
+++ /dev/null
@@ -1,132 +0,0 @@
-package sheets
-
-import (
- "net/http"
- "net/url"
- "strings"
- "time"
-)
-
-// modeCookie carries the viewer's light/dark preference.
-//
-// 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 written into the page, which is what
-// lets the choice resolve before first paint.
-const modeCookie = "sheets_mode"
-
-// The three states, and they are three rather than two on purpose:
-// "follow the machine" is a real preference, and an app that only
-// offers light and dark forces everyone to restate a choice their OS
-// already knows.
-const (
- modeAuto = "auto"
- modeLight = "light"
- modeDark = "dark"
-)
-
-// modeCookieMaxAge is a year. A preference somebody set once should not
-// quietly expire back to the default a month later.
-const modeCookieMaxAge = 365 * 24 * 60 * 60
-
-// modeFrom reads the viewer's light/dark choice, defaulting to auto.
-//
-// The value is checked against the three names rather than merely
-// sanitised: it is written into a data attribute that selects a CSS
-// rule, and an allowlist of three strings cannot be wrong in the way an
-// escaping rule can.
-func modeFrom(r *http.Request) string {
- c, err := r.Cookie(modeCookie)
- if err != nil {
- return modeAuto
- }
- switch c.Value {
- case modeLight, modeDark, modeAuto:
- return c.Value
- }
- return modeAuto
-}
-
-// themeAttr is what goes in <html data-theme="…">, and it is empty for
-// auto.
-//
-// Empty matters. rastrillo's theme.css declares every colour once as
-// light-dark(light, dark) under `color-scheme: light dark`, and its
-// whole toggle is two rules — `:root[data-theme="light"]` and
-// `:root[data-theme="dark"]` — which re-resolve every one of those
-// calls by setting color-scheme. With no attribute the browser resolves
-// them against the OS, which IS what auto means.
-//
-// This is also the bug this function exists to prevent recurring. The
-// attribute used to be handed rastrillo's THEME NAME — day, plain,
-// signal — which are the palette files an app picks once at scaffold
-// (this app serves day through Estilo), not a per-person
-// setting. Neither toggle rule matched, so an explicit choice never
-// beat the OS and the switcher did nothing at all. Theme and scheme are
-// orthogonal: this app ships `day` (ruling 7) and every person picks
-// their own light or dark within it.
-func themeAttr(mode string) string {
- if mode == modeLight || mode == modeDark {
- return mode
- }
- return ""
-}
-
-// handleMode sets the preference and returns the person to where they
-// were.
-func (a *App) handleMode(w http.ResponseWriter, r *http.Request) {
- mode := r.FormValue("mode")
- switch mode {
- case modeLight, modeDark, modeAuto:
- default:
- http.Error(w, "Unknown display mode.", http.StatusBadRequest)
- return
- }
- http.SetCookie(w, &http.Cookie{
- Name: modeCookie,
- Value: mode,
- Path: "/",
- // Not HttpOnly: this is a display preference, not a
- // credential, and a client-side toggle should be able to set it
- // without a round trip when one is added.
- SameSite: http.SameSiteLaxMode,
- Secure: r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https"),
- MaxAge: modeCookieMaxAge,
- Expires: time.Now().Add(modeCookieMaxAge * 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 scheme-relative opening is discarded for the
-// root.
-//
-// Scheme-relative has two spellings and only one of them is obvious.
-// "//host" is read off-site by every browser. So is "/\host": browsers
-// normalise the backslash to a forward slash before resolving, which
-// the URL Standard requires of them. url.Parse does neither — it
-// reports an empty Scheme and an empty Host for the second — so the
-// parse below cannot be what rejects it, and the check has to happen
-// before it.
-//
-// Today `u.RequestURI()` happens to percent-encode that backslash, so
-// the header comes out as a same-origin path and no browser is actually
-// sent anywhere. That is an accident of the encoder, not a decision by
-// this function, and it is one refactor away from not being true. The
-// explicit check is what turns it into a decision.
-func safeReturn(raw string) string {
- if raw == "" || raw[0] != '/' {
- return "/"
- }
- if len(raw) > 1 && (raw[1] == '/' || raw[1] == '\\') {
- 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
index 2a17d42..7772174 100644
--- a/internal/sheets/theme_test.go
+++ b/internal/sheets/theme_test.go
@@ -1,5 +1,13 @@
package sheets
+// Appearance (light / dark / system) is estilo's now: chrome.Appearance
+// reads the estilo_appearance cookie, chrome.HandleAppearance serves
+// POST /appearance, and chrome.ThemeAttr writes <html data-theme>
+// (spec §8.3). Sheets' own /mode, its sheets_mode cookie and "auto" are
+// retired. What these tests keep is the reason this file existed: the
+// choice is in the first response, never fetched, and "system" writes no
+// attribute at all.
+
import (
"net/http"
"net/http/httptest"
@@ -7,56 +15,25 @@ import (
"strings"
"testing"
+ "amadan.net/oficina/estilo.git/chrome"
"github.com/carlosframework/rastrillo/ui"
)
-func TestModeDefaultsToAutoWithNoCookie(t *testing.T) {
- r := httptest.NewRequest(http.MethodGet, "/", nil)
- if got := modeFrom(r); got != modeAuto {
- t.Fatalf("modeFrom with no cookie = %q, want %q", got, modeAuto)
- }
- // Auto means NO attribute. rastrillo's toggle is two rules keyed on
- // data-theme, and the browser falls back to the OS only when
- // neither matches — so an attribute here, of any value, would take
- // the OS out of the decision.
- if got := themeAttr(modeAuto); got != "" {
- t.Fatalf("themeAttr(auto) = %q, want empty so the OS decides", got)
- }
-}
-
-func TestModeReadsTheCookie(t *testing.T) {
- for _, want := range []string{modeLight, modeDark, modeAuto} {
- r := httptest.NewRequest(http.MethodGet, "/", nil)
- r.AddCookie(&http.Cookie{Name: modeCookie, Value: want})
- if got := modeFrom(r); got != want {
- t.Errorf("modeFrom = %q, want %q", got, want)
- }
- }
-}
-
-// The bug this whole file exists to stop coming back: data-theme takes
-// the light/dark SCHEME, not rastrillo's theme NAME. day, plain and
-// signal are palette files an app picks once at scaffold; feeding one
-// to the attribute matches neither toggle rule, so an explicit choice
-// never beats the OS and the switcher silently does nothing.
+// The bug theme.go was written to stop coming back: data-theme takes the
+// light/dark SCHEME, not rastrillo's theme NAME. A cookie holding a theme
+// name must produce no attribute.
func TestThemeAttrIsASchemeNeverAThemeName(t *testing.T) {
- if got := themeAttr(modeLight); got != "light" {
- t.Errorf("themeAttr(light) = %q, want \"light\"", got)
- }
- if got := themeAttr(modeDark); got != "dark" {
- t.Errorf("themeAttr(dark) = %q, want \"dark\"", got)
- }
for _, name := range ui.ThemeNames() {
r := httptest.NewRequest(http.MethodGet, "/", nil)
- r.AddCookie(&http.Cookie{Name: modeCookie, Value: name})
- if got := themeAttr(modeFrom(r)); got != "" {
+ r.AddCookie(&http.Cookie{Name: chrome.AppearanceCookie, Value: name})
+ if got := chrome.ThemeAttr(chrome.Appearance(r)); got != "" {
t.Errorf("a %q cookie produced data-theme=%q; theme names are not schemes", name, got)
}
}
}
// theme.css's toggle is exactly these two selectors. If rastrillo ever
-// changes them, everything above is wrong and this says so.
+// changes them, ThemeAttr is writing an attribute nothing reads.
func TestRastrillosToggleStillKeysOnLightAndDark(t *testing.T) {
css, err := staticFiles().(interface {
ReadFile(string) ([]byte, error)
@@ -66,130 +43,104 @@ func TestRastrillosToggleStillKeysOnLightAndDark(t *testing.T) {
}
for _, want := range []string{`[data-theme="light"]`, `[data-theme="dark"]`} {
if !strings.Contains(string(css), want) {
- t.Fatalf("theme.css no longer carries %s — the light/dark toggle moved, "+
- "and themeAttr is now writing an attribute nothing reads", want)
- }
- }
-}
-
-// 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 TestModeRejectsAnUnknownValue(t *testing.T) {
- for _, junk := range []string{"../../etc/passwd", "", "DARK", "dark evil", "day"} {
- r := httptest.NewRequest(http.MethodGet, "/", nil)
- r.AddCookie(&http.Cookie{Name: modeCookie, Value: junk})
- if got := modeFrom(r); got != modeAuto {
- t.Errorf("modeFrom with cookie %q = %q, want the %q floor", junk, got, modeAuto)
+ t.Fatalf("theme.css no longer carries %s — the light/dark toggle moved", want)
}
}
}
// 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 TestModeIsInTheFirstResponseNotFetched(t *testing.T) {
+// coloured cell. This fails the moment the decision moves into script.
+func TestAppearanceIsInTheFirstResponseNotFetched(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
- req.AddCookie(&http.Cookie{Name: modeCookie, Value: modeDark})
+ req.AddCookie(&http.Cookie{Name: chrome.AppearanceCookie, Value: "dark"})
New(Config{}).Router().ServeHTTP(rec, req)
-
- if !strings.Contains(rec.Body.String(), `data-theme="dark"`) {
- t.Fatal("the rendered page does not carry the choice; it would flash on load")
+ if !strings.Contains(rec.Body.String(), `<html lang="en" dir="ltr" data-theme="dark">`) {
+ t.Fatal("the rendered page does not carry the choice in its first bytes; it would flash on load")
}
}
-// Auto must emit no attribute at all, not an empty one: an empty
-// data-theme matches neither toggle rule but still looks deliberate,
-// and the next person to read the HTML would think it was.
-func TestAutoEmitsNoThemeAttributeAtAll(t *testing.T) {
+// System must emit no attribute at all, not an empty one, and the
+// retired cookie is ignored rather than honoured.
+func TestSystemEmitsNoThemeAttributeAtAll(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.AddCookie(&http.Cookie{Name: "sheets_mode", Value: "dark"})
New(Config{}).Router().ServeHTTP(rec, req)
if strings.Contains(rec.Body.String(), "data-theme") {
t.Fatal("a viewer with no preference got a data-theme attribute; the OS should decide")
}
}
-func TestPostModeSetsTheCookieAndReturns(t *testing.T) {
- form := url.Values{"mode": {modeDark}, "return": {"/sheets"}}
- req := httptest.NewRequest(http.MethodPost, "/mode", strings.NewReader(form.Encode()))
+func postAppearance(t *testing.T, form url.Values) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPost, "/appearance", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
New(Config{}).Router().ServeHTTP(rec, req)
+ return rec
+}
- if rec.Code != http.StatusSeeOther {
- t.Fatalf("POST /mode = %d, want 303", rec.Code)
- }
- if loc := rec.Header().Get("Location"); loc != "/sheets" {
- t.Fatalf("Location = %q, want /sheets", loc)
+func TestPostAppearanceSetsTheCookieAndReturns(t *testing.T) {
+ rec := postAppearance(t, url.Values{"mode": {"dark"}, "return_to": {"/sheets/3/table?page=2"}})
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/sheets/3/table?page=2" {
+ t.Fatalf("POST /appearance = %d %q, want 303 back to the page", rec.Code, rec.Header().Get("Location"))
}
var found bool
for _, c := range rec.Result().Cookies() {
- if c.Name == modeCookie && c.Value == modeDark {
- found = true
- }
+ found = found || (c.Name == chrome.AppearanceCookie && c.Value == "dark")
}
if !found {
- t.Fatalf("no %s cookie was set: %v", modeCookie, rec.Result().Cookies())
+ t.Fatalf("no %s cookie was set: %v", chrome.AppearanceCookie, rec.Result().Cookies())
}
-}
-
-func TestPostModeRefusesAnUnknownMode(t *testing.T) {
- form := url.Values{"mode": {"neon"}}
- req := httptest.NewRequest(http.MethodPost, "/mode", strings.NewReader(form.Encode()))
+ if rec := postAppearance(t, url.Values{"mode": {"auto"}}); rec.Code != http.StatusBadRequest {
+ t.Fatalf("the retired \"auto\" = %d, want 400", rec.Code)
+ }
+ // The retired route is gone, not quietly kept.
+ if rec := postAppearance(t, nil); rec.Code == http.StatusNotFound {
+ t.Fatal("/appearance is not mounted")
+ }
+ req := httptest.NewRequest(http.MethodPost, "/mode", strings.NewReader("mode=dark"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- rec := httptest.NewRecorder()
+ rec = httptest.NewRecorder()
New(Config{}).Router().ServeHTTP(rec, req)
- if rec.Code != http.StatusBadRequest {
- t.Fatalf("POST /mode with an unknown mode = %d, want 400", rec.Code)
+ if rec.Code != http.StatusMethodNotAllowed && rec.Code != http.StatusNotFound {
+ t.Fatalf("POST /mode = %d; the retired route still answers", 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 TestModeReturnIsNotAnOpenRedirect(t *testing.T) {
- for _, hostile := range []string{
- "https://evil.example.com/",
- "//evil.example.com/",
- "http://evil.example.com",
- "javascript:alert(1)",
- // The backslash spelling of the same attack. Browsers
- // normalise "\" to "/" and read these as scheme-relative,
- // exactly as they read "//" — but url.Parse does not, so it
- // reports an empty Scheme and an empty Host and every check
- // below passes. The check has to be made before the parse.
- `/\evil.example.com/`,
- `/\/evil.example.com`,
- `\\evil.example.com`,
- } {
- if got := safeReturn(hostile); got != "/" {
- t.Errorf("safeReturn(%q) = %q, want %q", hostile, got, "/")
+// A form field that becomes a Location header is an open redirect unless
+// it is constrained to a path on this origin; rastrillo's SafeReturn is
+// the one check, and the control-character case is the one a prefix
+// test misses.
+func TestAppearanceReturnIsNotAnOpenRedirect(t *testing.T) {
+ for _, hostile := range []string{"https://evil.example.com/", "//evil.example.com/", `/\evil.example.com/`, "/\t/evil.example.com", "javascript:alert(1)"} {
+ rec := postAppearance(t, url.Values{"mode": {"light"}, "return_to": {hostile}})
+ if rec.Header().Get("Location") != "/" {
+ t.Errorf("return_to %q went to %q, want /", hostile, rec.Header().Get("Location"))
}
}
- // 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 switcher is chrome, and chrome that needs JavaScript is chrome
-// somebody who needs dark mode cannot reach.
-func TestModeSwitcherIsAForm(t *testing.T) {
- // Asked of /sheets, not of "/": the switcher lives in the layout's
- // account menu, so any signed-in page proves it, and "/" is no
- // longer one — it redirects a signed-in visitor onward.
+// somebody who needs dark mode cannot reach. It is a form in the account
+// menu, under an Appearance heading, with the current choice marked by
+// aria-pressed on every option (the audit found the current one marked
+// with nothing a person could see).
+func TestAppearanceIsAFormWithTheCurrentChoiceMarked(t *testing.T) {
app, cookies := signIn(t)
- body := do(t, app, as(httptest.NewRequest(http.MethodGet, "/sheets", nil), cookies)).Body.String()
- if !strings.Contains(body, `action="/mode"`) {
- t.Fatal("the signed-in page offers no display-mode form")
- }
- for _, want := range []string{`value="light"`, `value="dark"`, `value="auto"`} {
+ req := as(httptest.NewRequest(http.MethodGet, "/sheets", nil), cookies)
+ req.AddCookie(&http.Cookie{Name: chrome.AppearanceCookie, Value: "dark"})
+ body := do(t, app, req).Body.String()
+ for _, want := range []string{
+ `<p estilo-menu-heading id="estilo-appearance">Appearance</p>`,
+ `<form method="post" action="/appearance" role="group" aria-labelledby="estilo-appearance">`,
+ `<input type="hidden" name="return_to" value="/sheets">`,
+ `value="light" aria-pressed="false"`, `value="dark" aria-pressed="true"`, `value="system" aria-pressed="false"`,
+ } {
if !strings.Contains(body, want) {
- t.Errorf("the switcher is missing %s", want)
+ t.Errorf("the account menu lacks %s", want)
}
}
}
diff --git a/tools/drive/chrome.mjs b/tools/drive/chrome.mjs
index 95aaa11..62ccdf9 100644
--- a/tools/drive/chrome.mjs
+++ b/tools/drive/chrome.mjs
@@ -73,12 +73,12 @@ await page.waitForURL(/\/sheets/);
* scriptless path as well as the button. */
await Promise.all([
page.waitForURL(/\/sheets\/\d+$/),
- page.click('[rst-shell-tail] form[action="/sheets"] button[type=submit]'),
+ page.click('[estilo-bar-actions] form[action="/sheets"] button[type=submit]'),
]);
-await page.fill('[data-doc-title-input]', 'Commission Q2');
+await page.fill('[estilo-bar-title-input]', 'Commission Q2');
await Promise.all([
page.waitForURL(/\/sheets\/\d+$/),
- page.press('[data-doc-title-input]', 'Enter'),
+ page.press('[estilo-bar-title-input]', 'Enter'),
]);
const url = page.url();
await page.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet());
@@ -237,11 +237,12 @@ console.log('\n— screenshots and reflow');
await page.evaluate(() => document.activeElement.blur());
await page.screenshot({ path: `${OUT}/sheet-light.png` });
-/* Dark, chosen explicitly through the form the account menu posts. */
+/* Dark, chosen explicitly through the form the account menu posts
+ * (estilo's /appearance, which replaced /mode). */
await page.evaluate(() => {
const f = document.createElement('form');
- f.method = 'post'; f.action = '/mode';
- f.innerHTML = '<input name="return" value="' + location.pathname + '"><input name="mode" value="dark">';
+ f.method = 'post'; f.action = '/appearance';
+ f.innerHTML = '<input name="return_to" value="' + location.pathname + '"><input name="mode" value="dark">';
document.body.appendChild(f); f.submit();
});
await page.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet());
diff --git a/tools/drive/clipboard.mjs b/tools/drive/clipboard.mjs
index 709b96a..a240fdd 100644
--- a/tools/drive/clipboard.mjs
+++ b/tools/drive/clipboard.mjs
@@ -36,9 +36,9 @@ const mm=[...fs.readFileSync(process.env.SHEETS_LOG,'utf8').matchAll(/http:\/\/1
await page.goto(mm[0]); await page.waitForURL(/\/sheets/);
// Create from the top bar, then name it by renaming: the list's #title
// field is gone, so a sheet is born "Untitled sheet" and named after.
-await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.click('[rst-shell-tail] form[action="/sheets"] button[type=submit]')]);
-await page.fill('[data-doc-title-input]','Clip2');
-await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.press('[data-doc-title-input]','Enter')]);
+await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.click('[estilo-bar-actions] form[action="/sheets"] button[type=submit]')]);
+await page.fill('[estilo-bar-title-input]','Clip2');
+await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.press('[estilo-bar-title-input]','Enter')]);
await page.waitForFunction(()=>window.SheetsGrid&&window.SheetsGrid.worksheet());
const w=(r,c,v)=>page.evaluate(([r,c,v])=>window.SheetsGrid.write(r,c,v),[r,c,v]);
const sel=(x1,y1,x2,y2)=>page.evaluate(([a,b,c,d])=>window.SheetsGrid.worksheet().updateSelectionFromCoords(a,b,c,d),[x1,y1,x2,y2]);
diff --git a/tools/drive/drive.mjs b/tools/drive/drive.mjs
index 01bb025..311dbd1 100644
--- a/tools/drive/drive.mjs
+++ b/tools/drive/drive.mjs
@@ -75,7 +75,7 @@ page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
// 1. Sign in, the way a person does.
const before = fs.readFileSync(LOG, 'utf8').length;
await page.goto(base + '/signin');
-await page.fill('#address', 'paul@example.com');
+await page.fill('input[name=address]', 'paul@example.com');
await page.click('form[action="/signin"] button[type=submit]');
await page.waitForURL(/signin\?sent=1/, { timeout: 15000 });
@@ -93,7 +93,7 @@ console.log('signed in as:', (await page.textContent('body')).includes('Signed i
await page.goto(base + '/sheets');
// Not networkidle: the SSE stream is a long-lived connection, so the
// network on this page is never idle by design.
-await page.click('[rst-shell-tail] form[action="/sheets"] button[type=submit]');
+await page.click('[estilo-bar-actions] form[action="/sheets"] button[type=submit]');
await page.waitForURL(/\/sheets\/\d+$/, { timeout: 15000 });
const url = page.url();
console.log('sheet url:', url);
@@ -101,10 +101,10 @@ console.log('sheet url:', url);
// 2b. Rename it from the top bar, which is now the only place a sheet
// is ever named — and a real form submit, so this covers the
// scriptless path too.
-await page.fill('[data-doc-title-input]', 'Browser drive');
-await page.press('[data-doc-title-input]', 'Enter');
+await page.fill('[estilo-bar-title-input]', 'Browser drive');
+await page.press('[estilo-bar-title-input]', 'Enter');
await page.waitForURL(/\/sheets\/\d+$/, { timeout: 15000 });
-console.log('renamed to:', JSON.stringify(await page.inputValue('[data-doc-title-input]')));
+console.log('renamed to:', JSON.stringify(await page.inputValue('[estilo-bar-title-input]')));
// 3. The grid must actually render.
await page.waitForSelector('.jexcel, .jss_worksheet, table', { timeout: 15000 });
diff --git a/tools/drive/drop.mjs b/tools/drive/drop.mjs
index c96deba..1751bd8 100644
--- a/tools/drive/drop.mjs
+++ b/tools/drive/drop.mjs
@@ -71,7 +71,7 @@ function check(name, ok, detail) {
* loopback origin with no SMTP — see internal/sheets/auth.go. */
const before = fs.readFileSync(LOG, 'utf8').length;
await page.goto(base + '/signin');
-await page.fill('#address', 'paul@example.com');
+await page.fill('input[name=address]', 'paul@example.com');
await page.click('form[action="/signin"] button[type=submit]');
await page.waitForURL(/signin\?sent=1/, { timeout: 15000 });
const link = fs.readFileSync(LOG, 'utf8').slice(before).match(/auth\/verify\?token=([A-Za-z0-9_-]+)/);
@@ -224,11 +224,12 @@ check('...and the refusal does not outlive the dismissal',
console.log('\n— dark');
-await ctx.addCookies([{ name: 'sheets_mode', value: 'dark', url: base }]);
+await ctx.addCookies([{ name: 'estilo_appearance', value: 'dark', url: base }]);
await page.goto(base + '/sheets');
await page.evaluate(helper);
await drag(1);
-/* The cookie is sheets_mode. Naming it "mode" renders the light page
+/* The cookie is estilo_appearance (estilo's chrome.AppearanceCookie; it
+ * was sheets_mode until round 2). Naming it wrong renders the light page
* and the screenshot proves nothing — which it once did, and the only
* tell was that the two images were pixel-identical. */
check('dark really is dark', (await page.getAttribute('html', 'data-theme')) === 'dark');
@@ -236,7 +237,7 @@ await page.screenshot({ path: OUT + '/list-dragging-dark.png' });
console.log('\n— the drop imports');
-await ctx.addCookies([{ name: 'sheets_mode', value: 'light', url: base }]);
+await ctx.addCookies([{ name: 'estilo_appearance', value: 'light', url: base }]);
await page.goto(base + '/sheets');
await page.evaluate(helper);
await page.evaluate(([b64]) => {
diff --git a/tools/drive/menus.mjs b/tools/drive/menus.mjs
index d508b85..2d077f3 100644
--- a/tools/drive/menus.mjs
+++ b/tools/drive/menus.mjs
@@ -51,9 +51,9 @@ await page.goto(mm[0]); await page.waitForURL(/\/sheets/);
// No sheets yet: Create from the top bar, then name it by renaming.
// The list's #title field is gone — a sheet is born "Untitled sheet".
if(!(await page.$('[data-sheet-list]'))){
- await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.click('[rst-shell-tail] form[action="/sheets"] button[type=submit]')]);
- await page.fill('[data-doc-title-input]','Audit');
- await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.press('[data-doc-title-input]','Enter')]);
+ await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.click('[estilo-bar-actions] form[action="/sheets"] button[type=submit]')]);
+ await page.fill('[estilo-bar-title-input]','Audit');
+ await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.press('[estilo-bar-title-input]','Enter')]);
}
else {await page.click('[data-sheet-list] [rst-row-main] a'); await page.waitForURL(/\/sheets\/\d+$/);}
const url=page.url();
@@ -75,7 +75,7 @@ const items=await page.evaluate(()=>{
const out=[];
document.querySelectorAll('[data-menubar] > details').forEach((d,mi)=>{
const menu=d.querySelector(':scope > summary').textContent.trim();
- d.querySelectorAll('[rst-dropdown-menu] a, [rst-dropdown-menu] button').forEach(el=>{
+ d.querySelectorAll('[estilo-menu] a, [estilo-menu] button').forEach(el=>{
out.push({menu, mi, label:el.textContent.trim().replace(/\s+/g,' ').slice(0,28),
kind:el.tagName, cmd:el.dataset.cmd||'', value:el.dataset.value!==undefined?el.dataset.value:(el.dataset.fn||''),
href:el.getAttribute('href')||'', inForm:!!el.closest('form'),
@@ -98,8 +98,8 @@ for (const it of items) {
try {
await page.click(`[data-menubar] > details:nth-child(${it.mi+1}) > summary`);
if (it.sub) await page.click(`[data-menubar] > details:nth-child(${it.mi+1}) details[rst-menu-group] >> summary:text-is(${JSON.stringify(it.sub)})`);
- const sel=`[data-menubar] > details:nth-child(${it.mi+1}) [rst-dropdown-menu] [data-cmd="${it.cmd}"]`+
- (it.value?`[data-value="${it.value}"], [data-menubar] > details:nth-child(${it.mi+1}) [rst-dropdown-menu] [data-cmd="${it.cmd}"][data-fn="${it.value}"]`:'');
+ const sel=`[data-menubar] > details:nth-child(${it.mi+1}) [estilo-menu] [data-cmd="${it.cmd}"]`+
+ (it.value?`[data-value="${it.value}"], [data-menubar] > details:nth-child(${it.mi+1}) [estilo-menu] [data-cmd="${it.cmd}"][data-fn="${it.value}"]`:'');
await page.click(sel+' >> nth=0', {timeout:5000});
} catch(e){ console.log(`ERR ${it.menu} > ${it.label} (${String(e).split('\n')[0].slice(0,70)})`); continue; }
await page.waitForTimeout(600);
diff --git a/tools/drive/modal.mjs b/tools/drive/modal.mjs
new file mode 100644
index 0000000..e3f258e
--- /dev/null
+++ b/tools/drive/modal.mjs
@@ -0,0 +1,135 @@
+/* A modal dialog owns the keyboard: no sheet command runs behind it.
+ *
+ * The Keyboard shortcuts dialog (account menu → Help) is a native modal
+ * <dialog>. Native modality makes the page behind it inert to the
+ * pointer and to focus, but not to key events: a keydown on the dialog's
+ * Done button still bubbles to document, where commands.js, clipboard.js
+ * and the vendored grid all listen. Astra found Ctrl+D inside the open
+ * dialog filling A1 down over A2 and persisting (#39, finding 1).
+ *
+ * This opens the dialog on a real sheet, presses the editing keys, closes
+ * it, commits anything a hidden editor might be holding, and asserts that
+ * nothing was POSTed and the persisted cells are unchanged after a
+ * reload. Chromium and WebKit, each with its own session.
+ *
+ * go build -o /tmp/sheets ./cmd/sheets
+ * SHEETS_ORIGIN=http://127.0.0.1:8799 SHEETS_INSTANCE_KEY=dev SHEETS_OWNERS=paul@example.com \
+ * SHEETS_DEK="$(head -c 32 /dev/urandom | base64)" /tmp/sheets --db /tmp/s.db --socket /tmp/s.sock > /tmp/s.log 2>&1 &
+ * SHEETS_SOCK=/tmp/s.sock SHEETS_LOG=/tmp/s.log PLAYWRIGHT=/path/to/playwright/index.mjs node tools/drive/modal.mjs
+ */
+import net from 'node:net';
+import fs from 'node:fs';
+const pw = await import(process.env.PLAYWRIGHT || 'playwright');
+const SOCK = process.env.SHEETS_SOCK || '/tmp/s.sock';
+const LOG = process.env.SHEETS_LOG || '/tmp/s.log';
+const PORT = 8799;
+const base = `http://127.0.0.1:${PORT}`;
+
+const proxy = net.createServer((c) => {
+ const u = net.connect(SOCK);
+ c.pipe(u); u.pipe(c);
+ c.on('error', () => u.destroy()); u.on('error', () => c.destroy());
+});
+await new Promise((r) => proxy.listen(PORT, '127.0.0.1', r));
+
+let failed = 0;
+const check = (name, ok, detail = '') => {
+ if (!ok) failed++;
+ console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}${ok || !detail ? '' : ' — ' + detail}`);
+};
+
+async function signIn(page) {
+ const before = fs.readFileSync(LOG, 'utf8').length;
+ await page.goto(base + '/signin');
+ await page.fill('input[name=address]', 'paul@example.com');
+ await Promise.all([page.waitForURL(/sent/), page.click('form[action="/signin"] button[type=submit]')]);
+ let m = null;
+ for (let i = 0; i < 50 && !m; i++) {
+ m = [...fs.readFileSync(LOG, 'utf8').slice(before).matchAll(/http:\/\/127\.0\.0\.1:8799\/auth\/verify\?[^\s\\"]+/g)].pop();
+ if (!m) await page.waitForTimeout(100);
+ }
+ await page.goto(m[0]);
+}
+
+const cell = (page, c, r) => page.evaluate(([c, r]) => {
+ const ws = window.SheetsGrid.worksheet();
+ return { value: ws.getValueFromCoords(c, r), bold: ws.records[r][c].element.hasAttribute('data-b') };
+}, [c, r]);
+
+for (const name of ['chromium', 'webkit']) {
+ console.log(`\n— ${name}`);
+ const browser = await pw[name].launch();
+ const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await signIn(page);
+ await Promise.all([page.waitForURL(/\/sheets\/\d+$/), page.click('[estilo-bar-actions] form[action="/sheets"] button')]);
+ const sheet = page.url();
+ await page.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet(), null, { timeout: 15000 });
+ await page.evaluate(() => { window.SheetsGrid.write(0, 0, 'Invoice'); window.SheetsGrid.write(1, 0, '920.5'); });
+ await page.waitForTimeout(600);
+ await page.reload();
+ await page.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet(), null, { timeout: 15000 });
+ await page.locator('td[data-x="0"][data-y="0"]').click();
+ await page.evaluate(() => window.SheetsGrid.worksheet().updateSelectionFromCoords(0, 0, 0, 1));
+
+ const posts = [];
+ page.on('request', (req) => { if (req.method() !== 'GET') posts.push(`${req.method()} ${new URL(req.url()).pathname}`); });
+
+ await page.click('[estilo-account] > summary');
+ await page.click('[estilo-account-panel] button[commandfor="sheets-shortcuts"]');
+ await page.waitForTimeout(200);
+ check('the dialog is open and owns focus', await page.evaluate(() => {
+ const d = document.getElementById('sheets-shortcuts');
+ return d.open && d.contains(document.activeElement);
+ }));
+ // Backspace only in Chromium: Playwright's WebKit build treats it as
+ // history Back on a non-editable focus, a browser default no page can
+ // or should suppress (Safari does not). Delete covers the same command.
+ const keys = ['Control+d', 'Meta+d', 'Control+b', 'Meta+b', 'Control+z', 'Meta+z', 'Control+x', 'Delete', 'x', 'Enter', 'Control+v'];
+ if (name === 'chromium') keys.push('Backspace');
+ for (const key of keys) {
+ await page.keyboard.press(key);
+ if (!(await page.evaluate(() => document.getElementById('sheets-shortcuts').open))) {
+ // Enter on Done closes the dialog, which is its job; reopen it for
+ // the keys that follow.
+ await page.click('[estilo-account] > summary');
+ await page.click('[estilo-account-panel] button[commandfor="sheets-shortcuts"]');
+ await page.waitForTimeout(100);
+ }
+ }
+ await page.waitForTimeout(400);
+ await page.keyboard.press('Escape');
+ await page.waitForTimeout(200);
+ check('Escape closes the dialog', !(await page.evaluate(() => document.getElementById('sheets-shortcuts').open)));
+ // Commit anything a hidden in-cell editor could be holding, by clicking
+ // another cell (D6).
+ await page.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet(), null, { timeout: 15000 });
+ const d6 = await page.evaluate(() => {
+ const r = window.SheetsGrid.worksheet().records[5][3].element.getBoundingClientRect();
+ return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
+ });
+ await page.mouse.click(d6.x, d6.y);
+ await page.waitForTimeout(800);
+ check('nothing was posted while the dialog owned the keyboard', posts.length === 0, posts.join(', '));
+
+ await page.goto(sheet);
+ await page.waitForFunction(() => window.SheetsGrid && window.SheetsGrid.worksheet(), null, { timeout: 15000 });
+ const a1 = await cell(page, 0, 0), a2 = await cell(page, 0, 1);
+ check('A1 is unchanged after a reload', a1.value === 'Invoice' && !a1.bold, JSON.stringify(a1));
+ check('A2 is unchanged after a reload', a2.value === '920.5' && !a2.bold, JSON.stringify(a2));
+
+ // The keys still work when no dialog is open: the suppression is the
+ // modal's, not a switch left off.
+ const wasBold = (await cell(page, 0, 0)).bold;
+ await page.locator('td[data-x="0"][data-y="0"]').click();
+ await page.keyboard.press('Control+b');
+ await page.waitForTimeout(600);
+ check('...and Ctrl+B still toggles bold once the dialog is closed', (await cell(page, 0, 0)).bold !== wasBold);
+ check('no page errors', errors.length === 0, errors.join(' | '));
+ await browser.close();
+}
+
+proxy.close();
+console.log(failed ? `\n${failed} check(s) failed` : '\nall checks passed');
+process.exit(failed ? 1 : 0);
diff --git a/tools/drive/tableformat.mjs b/tools/drive/tableformat.mjs
index 040da0f..535bf8e 100644
--- a/tools/drive/tableformat.mjs
+++ b/tools/drive/tableformat.mjs
@@ -28,9 +28,9 @@ const mm=[...fs.readFileSync(process.env.SHEETS_LOG,'utf8').matchAll(/http:\/\/1
await page.goto(mm[0]); await page.waitForURL(/\/sheets/);
// Create from the top bar, then name it by renaming: the list's #title
// field is gone, so a sheet is born "Untitled sheet" and named after.
-await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.click('[rst-shell-tail] form[action="/sheets"] button[type=submit]')]);
-await page.fill('[data-doc-title-input]','Table format');
-await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.press('[data-doc-title-input]','Enter')]);
+await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.click('[estilo-bar-actions] form[action="/sheets"] button[type=submit]')]);
+await page.fill('[estilo-bar-title-input]','Table format');
+await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.press('[estilo-bar-title-input]','Enter')]);
const url=page.url();
await page.waitForFunction(()=>window.SheetsGrid&&window.SheetsGrid.worksheet());
for(const [r,c,v] of [[0,0,'Region'],[0,1,'Revenue'],[1,0,'North'],[1,1,'1200'],[2,0,'South'],[2,1,'900']])