oficina / sheets Public

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.
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 686355f083a6670bdacca09b4f6bdd4a7cd7c875 parent a5c17ab
7 files changed, +95 −57
  • go.mod +2 −2
  • go.sum +4 −4
  • internal/sheets/auth.go +2 −1
  • internal/sheets/chrome.go +3 −11
  • internal/sheets/chrome_signin_test.go +21 −0
  • internal/sheets/signin.go +16 −39
  • internal/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)
+ }
+ }
+ }
+}