Take the sign-in door's words from estilo
The door's copy now lives in estilo (chrome.English, feat/door-copy):
the form's heading, label, help line and submit, the check-your-email
state, the signed-out page and the email subject. Sheets kept its own
copies, and its submit read "Email me a link", which Paul ruled out on
2026-10-01: the door's button reads "Sign in". With one copy set, no app
can drift from the others by overriding a line (polish r3 SUMMARY items
6 and 7).
Deleted: linkHelp, the form's Heading/AddressLabel/Help/Submit, the
sent heading/body/back link, the signed-out heading/body/button, and
homeCopy's signedout_body/signedout_action (unreachable: connect's
signedout state already renders the door's signed-out page). Tab titles
come from the door's Page(), and auth's Subject from
chrome.EmailSubject("Sheets"). The email body waits on rastrillo's
Config.Body.
Pins estilo at feat/door-copy fbd14e7; re-pin to estilo main after it
lands.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+68
−57
go.mod+1 −1go.sum+2 −14internal/sheets/auth.go+2 −1internal/sheets/chrome.go+0 −2internal/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 ./... go: downloading amadan.net/oficina/estilo.git v0.0.0-20261001075355-fbd14e759027 === fmt === === build === CGO_ENABLED=0 go build ./... === migrations === 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.078s ok amadan.net/oficina/sheets/internal/sheets 11.224s ok amadan.net/oficina/sheets/internal/sheets/calc 0.007s ok amadan.net/oficina/sheets/internal/sheets/format 0.003s ok amadan.net/oficina/sheets/internal/sheets/live 0.054s 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.827s ok amadan.net/oficina/sheets/internal/sheets/xlsx 0.287s ? 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.293s ok amadan.net/oficina/sheets/internal/sheets/live 1.059s
diff --git a/go.mod b/go.mod| index 8c52105..3fe274b 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -3,7 +3,7 @@ module amadan.net/oficina/sheets |
| go 1.25.0 |
| require ( |
| - amadan.net/oficina/estilo.git v0.0.0-20260929185827-41201439fd22 |
| + amadan.net/oficina/estilo.git v0.0.0-20261001075355-fbd14e759027 |
| 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 |
diff --git a/go.sum b/go.sum| index 5683e5c..77fdd60 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,17 +1,5 @@ |
| -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/estilo.git v0.0.0-20260929070530-5b2ce835e773 h1:9DIAhG3TaGG+LE2+8LvU73ZEhfK+oNn2y2p4kFjAAi0= |
| -amadan.net/oficina/estilo.git v0.0.0-20260929070530-5b2ce835e773/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| -amadan.net/oficina/estilo.git v0.0.0-20260929095046-d0664ee49914 h1:V0AyxXYCxqGZClOLqvppw+cCTXsn3b9NmRQ8CV5ZlXY= |
| -amadan.net/oficina/estilo.git v0.0.0-20260929095046-d0664ee49914/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| -amadan.net/oficina/estilo.git v0.0.0-20260929095838-0994675f496e h1:LTSmIWUS8RjPb6m94Ynf7moyFYeBXr08rdOr3nmNqvg= |
| -amadan.net/oficina/estilo.git v0.0.0-20260929095838-0994675f496e/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| -amadan.net/oficina/estilo.git v0.0.0-20260929165053-846b39772f3c h1:YYocIz3r3b4oIA0K/58JkSsmyTOCzMT/2GaW/MFbbPo= |
| -amadan.net/oficina/estilo.git v0.0.0-20260929165053-846b39772f3c/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| -amadan.net/oficina/estilo.git v0.0.0-20260929183653-cc2c1e56fb4b h1:UOxYBKsOSRHcHHYNKt0i6cB1Z4yT2soywOTWURYvBWo= |
| -amadan.net/oficina/estilo.git v0.0.0-20260929183653-cc2c1e56fb4b/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| -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/estilo.git v0.0.0-20261001075355-fbd14e759027 h1:+NFDWW5ROkLaFIsT9flDuAjNeQJJxj0Safk32UcRyn0= |
| +amadan.net/oficina/estilo.git v0.0.0-20261001075355-fbd14e759027/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= |
diff --git a/internal/sheets/auth.go b/internal/sheets/auth.go| index 3a28d68..ac16baa 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..60cfc5e 100644 |
| --- a/internal/sheets/chrome.go |
| +++ b/internal/sheets/chrome.go |
| @@ -252,8 +252,6 @@ var homeCopy = func() map[string]string { |
| 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/signin.go b/internal/sheets/signin.go| index c59e14a..c87f1fe 100644 |
| --- a/internal/sheets/signin.go |
| +++ b/internal/sheets/signin.go |
| @@ -10,16 +10,11 @@ import ( |
| "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." |
| +// 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) |
| + } |
| + } |
| + } |
| +} |