oficina / docs Public

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.
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 73a54618c84ad1465b0267935aedc0ca73307e84 parent cc1bd3c
10 files changed, +95 −72
  • go.mod +1 −1
  • go.sum +2 −14
  • internal/docs/auth.go +10 −19
  • internal/docs/auth_navigation.go +1 −1
  • internal/docs/auth_test.go +1 −1
  • internal/docs/chrome_test.go +3 −3
  • internal/docs/home_render.go +4 −11
  • internal/docs/render.go +4 −21
  • internal/docs/signin_door_test.go +68 −0
  • internal/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 = &notice, 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)
}