Speak the suite's sign-in door copy in Docs
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 Docs'. Reviewed by GPT-6-Astra via Codex (#74). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Squashed from chore/door-copy into main.
10 files changed,
+95
−72
go.mod+1 −1go.sum+2 −14internal/docs/auth.go+10 −19internal/docs/auth_navigation.go+1 −1internal/docs/auth_test.go+1 −1internal/docs/chrome_test.go+3 −3internal/docs/home_render.go+4 −11internal/docs/render.go+4 −21internal/docs/signin_door_test.go+68 −0internal/docs/signin_loop_test.go+1 −1
CI failed — run details
CI log
amadan: sync for CI failed: exit status 128: error: RPC failed; HTTP 429 curl 22 The requested URL returned error: 429 fatal: expected flush after ref listing (and could not reset the workspace: unlinkat /home/paulca/.local/state/amadan/runner/repos/oficina__docs/cache/go-mod/modernc.org/libc@v1.74.1/ioutil_darwin.go: permission denied)
diff --git a/go.mod b/go.mod| index 027a701..4ba10b7 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -3,7 +3,7 @@ module github.com/carlosframework/docs |
| go 1.26.5 |
| require ( |
| - amadan.net/oficina/estilo.git v0.0.0-20260929185827-41201439fd22 |
| + amadan.net/oficina/estilo.git v0.0.0-20261001084202-0b4d92c3dc16 |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| amadan.net/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf |
| amadan.net/rastrillo/native/server v0.0.0-20260923173546-2a5d07b2c8d3 |
diff --git a/go.sum b/go.sum| index 4ca18d2..1f66d59 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-20261001084202-0b4d92c3dc16 h1:F5v3x25dEj1rJNnBm71G7shN+fM+T2actUlaGPvoeH8= |
| +amadan.net/oficina/estilo.git v0.0.0-20261001084202-0b4d92c3dc16/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| 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/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf h1:SRKjCUgtk42WwR7VSn+V0eLNk20mfiY8dQmzdw13Sbo= |
diff --git a/internal/docs/auth.go b/internal/docs/auth.go| index 61b44eb..9fb5014 100644 |
| --- a/internal/docs/auth.go |
| +++ b/internal/docs/auth.go |
| @@ -419,6 +419,7 @@ func newAuthPlugin(cfg Config, writer *sql.DB, instanceKey string, mailer mail.S |
| Mailer: admissionMailer{next: mailer, list: list}, |
| Authorize: list.admits, |
| Logger: logger, |
| + Subject: chrome.EmailSubject("Docs"), |
| }) |
| } |
| @@ -487,28 +488,18 @@ func (h *handlers) signin(w http.ResponseWriter, r *http.Request) { |
| q := r.URL.Query() |
| icon := brand.Path("docs") |
| data := signinPage{} |
| - title := rastrillo.T(r, "docs.signin.title") |
| + var page string |
| if q.Get("sent") == "1" { |
| - title = rastrillo.T(r, "docs.signin.sent.title") |
| - data.Sent = &chrome.SignInNotice{ |
| - Icon: icon, |
| - App: "Docs", |
| - Sky: sky.For(r.Host), |
| - Heading: title, |
| - Body: rastrillo.T(r, "docs.signin.sent.body"), |
| - Link: &chrome.Link{Href: h.signinAgainHref(r), Label: rastrillo.T(r, "docs.signin.sent.again")}, |
| - } |
| + sent := chrome.SignInNotice{Icon: icon, App: "Docs", Sky: sky.For(r.Host)}.Sent(h.signinAgainHref(r)) |
| + data.Sent, page = &sent, sent.Page() |
| } else { |
| form := &chrome.SignIn{ |
| - Icon: icon, |
| - App: "Docs", |
| - Sky: sky.For(r.Host), |
| - Heading: title, |
| - Action: "/signin", |
| - AddressLabel: rastrillo.T(r, "docs.signin.address.label"), |
| - Help: rastrillo.T(r, "docs.signin.help"), |
| - Submit: rastrillo.T(r, "docs.signin.submit"), |
| + Icon: icon, |
| + App: "Docs", |
| + Sky: sky.For(r.Host), |
| + Action: "/signin", |
| } |
| + page = form.Page() |
| if q.Get("force") != "" { |
| form.Hidden = []chrome.Hidden{{Name: "force", Value: "1"}} |
| } |
| @@ -526,7 +517,7 @@ func (h *handlers) signin(w http.ResponseWriter, r *http.Request) { |
| } |
| data.Form = form |
| } |
| - data.Frame = h.frame(r, frameOptions{titleParts: []string{title}, signinPage: true}) |
| + data.Frame = h.frame(r, frameOptions{titleParts: []string{page}, signinPage: true}) |
| if err := h.render.page(w, r, "signin", data); err != nil { |
| h.logger.Error("docs: render signin", "err", err) |
| h.fail(w, r, http.StatusInternalServerError, failStatus) |
diff --git a/internal/docs/auth_navigation.go b/internal/docs/auth_navigation.go| index 6d5f261..89caf7b 100644 |
| --- a/internal/docs/auth_navigation.go |
| +++ b/internal/docs/auth_navigation.go |
| @@ -32,7 +32,7 @@ func (h *handlers) beginSignin(next http.HandlerFunc) http.HandlerFunc { |
| w.Header().Set("Cache-Control", "no-store") |
| w.Header().Set("Referrer-Policy", "no-referrer") |
| data := signinPage{ |
| - Frame: h.frame(r, frameOptions{titleParts: []string{rastrillo.T(r, "docs.signin.title")}, signinPage: true}), |
| + Frame: h.frame(r, frameOptions{titleParts: []string{chrome.SignIn{}.Page()}, signinPage: true}), |
| SubmitLabel: rastrillo.T(r, "docs.signin.keymail"), |
| ContinueURL: destination, |
| Door: chrome.SignInNotice{Icon: brand.Path("docs"), App: "Docs", Sky: sky.For(r.Host)}, |
diff --git a/internal/docs/auth_test.go b/internal/docs/auth_test.go| index 24b9a64..f021e8d 100644 |
| --- a/internal/docs/auth_test.go |
| +++ b/internal/docs/auth_test.go |
| @@ -115,7 +115,7 @@ func TestSignInPageRendersSignedOut(t *testing.T) { |
| t.Fatalf("GET /signin: status %d, want 200", rec.Code) |
| } |
| body := rec.Body.String() |
| - if !strings.Contains(body, `<label rst-field-label for="estilo-signin-address">docs.signin.address.label</label>`) { |
| + if !strings.Contains(body, `<label rst-field-label for="estilo-signin-address">Email address</label>`) { |
| t.Error("email input has no persistent associated translated label") |
| } |
| if !strings.Contains(body, `type="email" id="estilo-signin-address" name="address" required autofocus autocomplete="email"`) { |
diff --git a/internal/docs/chrome_test.go b/internal/docs/chrome_test.go| index 271a861..0433ba5 100644 |
| --- a/internal/docs/chrome_test.go |
| +++ b/internal/docs/chrome_test.go |
| @@ -56,9 +56,9 @@ func TestPageTitlesFollowTheSuitePattern(t *testing.T) { |
| {"search", "/?q=min", alice, "“min” · docs.app.name"}, |
| {"document", "/d/" + sqid, alice, "Minutes · docs.app.name"}, |
| {"share", "/d/" + sqid + "/share", alice, "docs.share.title · Minutes · docs.app.name"}, |
| - {"sign in", "/signin", nil, "docs.signin.title · docs.app.name"}, |
| - {"sent", "/signin?sent=1", nil, "docs.signin.sent.title · docs.app.name"}, |
| - {"signed out", "/signed-out", nil, "docs.signedout.title · docs.app.name"}, |
| + {"sign in", "/signin", nil, "Sign in · docs.app.name"}, |
| + {"sent", "/signin?sent=1", nil, "Check your email · docs.app.name"}, |
| + {"signed out", "/signed-out", nil, "Signed out · docs.app.name"}, |
| } |
| for _, c := range cases { |
| rec := getAccept(t, app, c.cookies, c.path) |
diff --git a/internal/docs/home_render.go b/internal/docs/home_render.go| index f2a22be..5911302 100644 |
| --- a/internal/docs/home_render.go |
| +++ b/internal/docs/home_render.go |
| @@ -32,17 +32,10 @@ func (rd *renderer) homeLink(w http.ResponseWriter, r *http.Request, state, addr |
| page.Action = "/signin/local" |
| page.ActionLabel = rastrillo.T(r, key+"local_action") |
| case "signedout": |
| - // The signed-out page (spec §9.2): "You’re signed out", with Sign |
| - // in going back through Home. |
| - page.SignedOut = &chrome.SignInNotice{ |
| - Icon: brand.Path("docs"), |
| - App: "Docs", |
| - Sky: sky.For(r.Host), |
| - Heading: rastrillo.T(r, "docs.signedout.heading"), |
| - Body: rastrillo.T(r, "docs.signedout.body"), |
| - Link: &chrome.Link{Href: "/signin", Label: rastrillo.T(r, "docs.chrome.signin")}, |
| - } |
| - page.Title = rastrillo.T(r, "docs.signedout.title") |
| + // The signed-out page (spec §9.2), in the door's words, with Sign |
| + // in going back through Home when it is configured. |
| + notice := chrome.SignInNotice{Icon: brand.Path("docs"), App: "Docs", Sky: sky.For(r.Host)}.SignedOut("/signin") |
| + page.SignedOut, page.Title = ¬ice, notice.Page() |
| case "success": |
| page.Action = "/" |
| page.ActionLabel = rastrillo.T(r, key+"documents_action") |
diff --git a/internal/docs/render.go b/internal/docs/render.go| index 0cdcb54..ef7d4c6 100644 |
| --- a/internal/docs/render.go |
| +++ b/internal/docs/render.go |
| @@ -32,14 +32,6 @@ import ( |
| // template or a handler), resolved through rastrillo.T at render |
| // time. cmd/docs/main.go wires it in as rastrillo.Options.BaseCatalog. |
| var Catalog = rastrillo.Catalog{ |
| - // The signed-out page (spec §9.2). Both sign-outs land here: the |
| - // standalone one, and in Home mode the one chained through |
| - // {issuer}/signout, which has ended the Oficina session too |
| - // (homeRoutes.signout). "This app, on this device" is true of both. |
| - "docs.signedout.title": "Signed out", |
| - "docs.signedout.heading": "You’re signed out", |
| - "docs.signedout.body": "You’ve signed out of Docs on this device.", |
| - |
| // docs.home.* — connecting a Docs account to the suite account. The |
| // hub is never named ("Home" leaves user copy, spec §10 and §13 Q3): |
| // the account is "your Oficina account", and the verb stays Connect. |
| @@ -286,20 +278,11 @@ var Catalog = rastrillo.Catalog{ |
| // either (rastrillo/auth's own handlers already refuse to be an |
| // oracle between "wrong" and "unknown"). |
| // |
| - // The copy is the suite's (spec §9.2): "Email me a link", the help |
| - // line saying what the link does and how long it lasts (keymaildev |
| - // signin's LinkTTL is 15 minutes), and a sent state that replaces the |
| - // form. The sent state cannot name the address, which the spec's copy |
| - // does: rastrillo/auth's Begin redirects to ?sent=1 and carries no |
| - // address, so it says "that address". |
| - "docs.signin.title": "Sign in", |
| - "docs.signin.address.label": "Email address", |
| - "docs.signin.help": "We’ll email you a link that signs you in. It works once and expires in 15 minutes.", |
| - "docs.signin.submit": "Email me a link", |
| + // The door's own words (heading, label, help, submit, the sent state |
| + // and the signed-out page) are estilo's chrome.English, not keys here: |
| + // one copy set for the suite. What stays is this app's: the outcome |
| + // errors, and the Keymail continuation's button. |
| "docs.signin.keymail": "Continue to Keymail", |
| - "docs.signin.sent.title": "Check your email", |
| - "docs.signin.sent.body": "If that address can sign in here, we’ve sent it a link. It works once and expires in 15 minutes.", |
| - "docs.signin.sent.again": "Use a different address", |
| "docs.signin.error.rate": "Too many attempts. Wait a few minutes, then try again.", |
| "docs.signin.error.address": "That doesn’t look like an email address.", |
| "docs.signin.error.expired": "That link has expired or was already used. Enter your email to get a new one.", |
diff --git a/internal/docs/signin_door_test.go b/internal/docs/signin_door_test.go| index 7554dde..8e557ed 100644 |
| --- a/internal/docs/signin_door_test.go |
| +++ b/internal/docs/signin_door_test.go |
| @@ -1,11 +1,14 @@ |
| package docs |
| import ( |
| + "html" |
| "net/http" |
| "net/http/httptest" |
| "regexp" |
| "strings" |
| "testing" |
| + |
| + "github.com/carlosframework/rastrillo" |
| ) |
| // The sign-in page is the Oficina door (estilo chrome): a card on the |
| @@ -40,3 +43,68 @@ func TestSignInPageIsTheDoor(t *testing.T) { |
| t.Error("sign-in page does not link sky.css") |
| } |
| } |
| + |
| +// The door's words are the suite's, with this app's English catalog |
| +// wired the way cmd/docs/main.go wires it: the submit reads "Sign in" |
| +// (Paul, 2026-10-01, never "Email me a link"), the help and sent copy are |
| +// the door's one set, and the three tab titles are "<Page> · Docs". |
| +func TestSignInDoorCopyIsTheSuites(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + loc, err := rastrillo.NewLocales(Locales, DefaultLocale, Catalog, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + h := loc.Middleware(app.Handler()) |
| + GET := func(p string) string { |
| + req := httptest.NewRequest(http.MethodGet, DefaultOrigin+p, nil) |
| + req.Header.Set("Accept", "text/html") |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, req) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("GET %s: %d", p, rec.Code) |
| + } |
| + return rec.Body.String() |
| + } |
| + title := func(body string) string { |
| + m := regexp.MustCompile(`<title>([^<]*)</title>`).FindStringSubmatch(body) |
| + if m == nil { |
| + return "" |
| + } |
| + return html.UnescapeString(m[1]) |
| + } |
| + cases := []struct { |
| + path, title string |
| + want []string |
| + }{ |
| + {"/signin", "Sign in · Docs", []string{ |
| + `>Sign in</button>`, |
| + "We’ll email you a link that signs you in. It works once and expires in 15 minutes.", |
| + }}, |
| + {"/signin?sent=1", "Check your email · Docs", []string{ |
| + "If that address can sign in here, we’ve sent it a link. It works once and expires in 15 minutes.", |
| + `>Use a different address</a>`, |
| + }}, |
| + {"/signed-out", "Signed out · Docs", []string{ |
| + "You’re signed out", |
| + "You’ve signed out of Docs on this device.", |
| + `href="/signin">Sign in</a>`, |
| + }}, |
| + } |
| + for _, c := range cases { |
| + body := GET(c.path) |
| + if got := title(body); got != c.title { |
| + t.Errorf("%s: <title> = %q, want %q", c.path, got, c.title) |
| + } |
| + if strings.Contains(body, "Email me a link") { |
| + t.Errorf("%s still says \"Email me a link\"", c.path) |
| + } |
| + for _, want := range c.want { |
| + if !strings.Contains(html.UnescapeString(body), want) { |
| + t.Errorf("%s lacks %q", c.path, want) |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/docs/signin_loop_test.go b/internal/docs/signin_loop_test.go| index fe8a9b2..58f01ed 100644 |
| --- a/internal/docs/signin_loop_test.go |
| +++ b/internal/docs/signin_loop_test.go |
| @@ -110,7 +110,7 @@ func TestUseADifferentAddressKeepsTheDeepLink(t *testing.T) { |
| b.do("GET", signin, "") |
| sent := b.do("POST", "/signin", "address=typo@example.com") |
| sentPage := b.do("GET", sent.Header().Get("Location"), "") |
| - m := regexp.MustCompile(`<p><a href="([^"]+)">docs.signin.sent.again</a></p>`).FindStringSubmatch(sentPage.Body.String()) |
| + m := regexp.MustCompile(`<p><a href="([^"]+)">Use a different address</a></p>`).FindStringSubmatch(sentPage.Body.String()) |
| if m == nil { |
| t.Fatalf("no \"Use a different address\" link on the sent page (%d)", sentPage.Code) |
| } |