Speak the suite's sign-in door copy in Sheets
The door's button reads 'Sign in' (was 'Email me a link'); sent and signed-out pages and titles come from estilo; the email subject is 'Sign in to Sheets'; the Oficina connection page says 'Connect'. home/connect moves to Home main for the reworded English. Reviewed by GPT-6-Astra via Codex (#43); the main merge and the nit were checked by the coordinating session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Squashed from chore/door-copy into main.
7 files changed,
+95
−57
go.mod+2 −2go.sum+4 −4internal/sheets/auth.go+2 −1internal/sheets/chrome.go+3 −11internal/sheets/chrome_signin_test.go+21 −0internal/sheets/signin.go+16 −39internal/sheets/signin_door_test.go+47 −0
CI passed — run details
CI log
amadan: .amadan/timeout: this job's budget is 15m0s (this runner allows up to 30m0s) === vet === CGO_ENABLED=0 go vet ./... CGO_ENABLED=0 go vet -tags browser ./internal/sheets/ === fmt === === build === CGO_ENABLED=0 go build ./... === migrations === go tool rastrillo migration check rastrillo migration check: models and migrations agree === go-test === CGO_ENABLED=0 go test -count=1 ./... ? amadan.net/oficina/sheets/cmd/sheets [no test files] ok amadan.net/oficina/sheets/internal/guardstest 0.080s ok amadan.net/oficina/sheets/internal/sheets 9.748s ok amadan.net/oficina/sheets/internal/sheets/calc 0.008s ok amadan.net/oficina/sheets/internal/sheets/format 0.003s ok amadan.net/oficina/sheets/internal/sheets/live 0.053s ok amadan.net/oficina/sheets/internal/sheets/seal 0.003s ok amadan.net/oficina/sheets/internal/sheets/sealtest 0.003s ok amadan.net/oficina/sheets/internal/sheets/store 2.798s ok amadan.net/oficina/sheets/internal/sheets/xlsx 0.285s ? amadan.net/oficina/sheets/tools/gencorpus [no test files] === race === go test -race -count=1 ./internal/sheets/store/... ./internal/sheets/live/... ok amadan.net/oficina/sheets/internal/sheets/store 46.300s ok amadan.net/oficina/sheets/internal/sheets/live 1.059s
diff --git a/go.mod b/go.mod| index a2004bd..75dbbb4 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-20260929185827-41201439fd22 |
| - amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9 |
| + amadan.net/oficina/estilo.git v0.0.0-20261001093750-530cb932e768 |
| + amadan.net/oficina/home/connect v0.0.0-20261001093757-2bec942ddd0c |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd |
| github.com/carlosframework/rastrillo v0.23.0 |
diff --git a/go.sum b/go.sum| index 6cb589e..54727f1 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,7 +1,7 @@ |
| -amadan.net/oficina/estilo.git v0.0.0-20260929185827-41201439fd22 h1:RR9qP85FU1cZdn788v1zSLgZbKMGBmSk6Q7Rezj5JYI= |
| -amadan.net/oficina/estilo.git v0.0.0-20260929185827-41201439fd22/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/estilo.git v0.0.0-20261001093750-530cb932e768 h1:bVscoa9fH5LdLDjlyecZhrX7JzWPT0blb57MH0FIm2U= |
| +amadan.net/oficina/estilo.git v0.0.0-20261001093750-530cb932e768/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| +amadan.net/oficina/home/connect v0.0.0-20261001093757-2bec942ddd0c h1:3yxO2D8BxDLMGfFV2o6Sp9Z91yLnjDCIuRKHVWORa8A= |
| +amadan.net/oficina/home/connect v0.0.0-20261001093757-2bec942ddd0c/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k= |
| amadan.net/rastrillo/native/server v0.0.0-20260923173546-2a5d07b2c8d3 h1:5EFx7BLJj/+G/rt8bTmeyMWVfF5s5Sns7ajTvX4w/I8= |
diff --git a/internal/sheets/auth.go b/internal/sheets/auth.go| index cb9f668..a0f7a2a 100644 |
| --- a/internal/sheets/auth.go |
| +++ b/internal/sheets/auth.go |
| @@ -1,6 +1,7 @@ |
| package sheets |
| import ( |
| + "amadan.net/oficina/estilo.git/chrome" |
| "amadan.net/oficina/home/connect" |
| "database/sql" |
| "errors" |
| @@ -166,7 +167,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: "Sign in to Sheets", |
| + Subject: chrome.EmailSubject("Sheets"), |
| Logger: logger, |
| }) |
| } |
diff --git a/internal/sheets/chrome.go b/internal/sheets/chrome.go| index 679f49b..3bdd9a2 100644 |
| --- a/internal/sheets/chrome.go |
| +++ b/internal/sheets/chrome.go |
| @@ -237,23 +237,15 @@ func (a *App) switcher() *chrome.Switcher { |
| } |
| } |
| -// homeCopy is connect's account-connection copy with the hub unnamed |
| -// (Paul's §13 answer 3): "your Oficina account", and the verb Connect. |
| +// homeCopy is connect's account-connection copy (which already leaves |
| +// the hub unnamed — "your Oficina account" — and says Connect, Paul's |
| +// §13 answer 3), with only Sheets' own words on top. |
| 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 |
| }() |
diff --git a/internal/sheets/chrome_signin_test.go b/internal/sheets/chrome_signin_test.go| index 1e8dea5..2736474 100644 |
| --- a/internal/sheets/chrome_signin_test.go |
| +++ b/internal/sheets/chrome_signin_test.go |
| @@ -149,3 +149,24 @@ func TestARevokedHomeUserIsNotBouncedBetweenTheRootAndSignIn(t *testing.T) { |
| t.Fatal("the revoked session survived") |
| } |
| } |
| + |
| +// Astra's door-copy review: the account-connection start page's action |
| +// is the verb the page is about, "Connect", not a bare "Continue". |
| +func TestConnectStartPageActionSaysConnect(t *testing.T) { |
| + f := newHomeFixture(t) |
| + f.local(t, signedInAddress) |
| + w := f.request(t, "GET", "/account/home", nil, false) |
| + if w.Code != http.StatusOK { |
| + t.Fatal(w.Code) |
| + } |
| + body := w.Body.String() |
| + if !strings.Contains(body, "Connect your Oficina account") { |
| + t.Fatal("not the connection start page") |
| + } |
| + if !strings.Contains(body, ">Connect</button>") { |
| + t.Error("the start action does not say Connect") |
| + } |
| + if strings.Contains(body, ">Continue</button>") { |
| + t.Error("the start action still says Continue") |
| + } |
| +} |
diff --git a/internal/sheets/signin.go b/internal/sheets/signin.go| index 9759ccb..199fe1c 100644 |
| --- a/internal/sheets/signin.go |
| +++ b/internal/sheets/signin.go |
| @@ -10,16 +10,11 @@ import ( |
| "amadan.net/rastrillo/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." |
| +// The sign-in pages are estilo's door (estilo-signin, estilo-signin-sent, |
| +// estilo-signed-out) and so are their words: the form's heading, label, |
| +// help line and "Sign in" submit, the sent state that replaces the form, |
| +// and the signed-out page all come from chrome.English, so every app in |
| +// the suite says the same thing. Only the error lines are this app's. |
| // 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 — |
| @@ -94,30 +89,18 @@ func (a *App) handleSignin(w http.ResponseWriter, r *http.Request) { |
| } |
| 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"), |
| - App: "Sheets", |
| - Sky: sky.For(r.Host), |
| - 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"}, |
| - }, |
| - }) |
| + // rastrillo's Begin redirects to ?sent=1 with no address, so the |
| + // page cannot echo it (Docs made the same call). |
| + sent := chrome.SignInNotice{Icon: brand.Path("sheets"), App: "Sheets", Sky: sky.For(r.Host)}.Sent(a.signinFormPath()) |
| + a.render(w, r, "signin", frame{parts: []string{sent.Page()}, signinPage: true}, http.StatusOK, signinView{Sent: &sent}) |
| return |
| } |
| text, field := signinError(q.Get("err")) |
| form := chrome.SignIn{ |
| - Icon: brand.Path("sheets"), |
| - App: "Sheets", |
| - Sky: sky.For(r.Host), |
| - Heading: "Sign in", |
| - Action: "/signin", |
| - AddressLabel: "Email address", |
| - Help: linkHelp, |
| - Submit: "Email me a link", |
| + Icon: brand.Path("sheets"), |
| + App: "Sheets", |
| + Sky: sky.For(r.Host), |
| + Action: "/signin", |
| } |
| if field { |
| form.AddressError = text |
| @@ -133,7 +116,7 @@ func (a *App) handleSignin(w http.ResponseWriter, r *http.Request) { |
| 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}) |
| + a.render(w, r, "signin", frame{parts: []string{form.Page()}, signinPage: true}, http.StatusOK, signinView{Form: form}) |
| } |
| // signedInLanding is where an already-signed-in /signin goes: the |
| @@ -152,14 +135,8 @@ func signedInLanding(r *http.Request) string { |
| // 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"), |
| - App: "Sheets", |
| - Sky: sky.For(r.Host), |
| - Heading: "You’re signed out", |
| - Body: "You’ve signed out of Sheets on this device.", |
| - Link: &chrome.Link{Href: "/signin", Label: "Sign in"}, |
| - }) |
| + out := chrome.SignInNotice{Icon: brand.Path("sheets"), App: "Sheets", Sky: sky.For(r.Host)}.SignedOut("/signin") |
| + a.render(w, r, "signed_out", frame{parts: []string{out.Page()}, signinPage: true}, status, out) |
| } |
| func (a *App) handleSignedOut(w http.ResponseWriter, r *http.Request) { |
diff --git a/internal/sheets/signin_door_test.go b/internal/sheets/signin_door_test.go| index 37a78d5..d88781d 100644 |
| --- a/internal/sheets/signin_door_test.go |
| +++ b/internal/sheets/signin_door_test.go |
| @@ -56,3 +56,50 @@ func TestKeymailContinuationIsTheDoor(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// The door's words are estilo's, not this app's (Paul, 2026-10-01: the |
| +// submit reads "Sign in", never "Email me a link"; 2026-09-23: the hub is |
| +// never named). Every state is pinned: the form, the sent state, and the |
| +// signed-out page in both modes, with their exact tab titles. |
| +func TestTheDoorSpeaksTheSuiteCopy(t *testing.T) { |
| + app := New(Config{}) |
| + f := newHomeFixture(t) |
| + for _, c := range []struct { |
| + name, title string |
| + body string |
| + want []string |
| + }{ |
| + {"form", "Sign in · Sheets", page(t, app, nil, "/signin").Body.String(), []string{ |
| + "<h1>Sign in</h1>", "Email address", |
| + "We’ll email you a link that signs you in. It works once and expires in 15 minutes.", |
| + `<button rst-btn="primary" type="submit">Sign in</button>`, |
| + }}, |
| + {"sent", "Check your email · Sheets", page(t, app, nil, "/signin?sent=1").Body.String(), []string{ |
| + "<h1>Check your email</h1>", |
| + "If that address can sign in here, we’ve sent it a link. It works once and expires in 15 minutes.", |
| + `<a href="/signin">Use a different address</a>`, |
| + }}, |
| + {"signed out", "Signed out · Sheets", page(t, app, nil, "/signed-out").Body.String(), []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>`, |
| + }}, |
| + {"signed out, Home configured", "Signed out · Sheets", f.request(t, "GET", "/signed-out", nil, false).Body.String(), []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 got := titleOf(t, c.body); got != c.title { |
| + t.Errorf("%s: <title> %q, want %q", c.name, got, c.title) |
| + } |
| + for _, want := range c.want { |
| + if !strings.Contains(c.body, want) { |
| + t.Errorf("%s lacks %s", c.name, want) |
| + } |
| + } |
| + for _, never := range []string{"Email me a link", "with Home", "Home account"} { |
| + if strings.Contains(c.body, never) { |
| + t.Errorf("%s says %q", c.name, never) |
| + } |
| + } |
| + } |
| +} |