The door owns its copy: "Sign in", one help line, door titles
Paul ruled on 2026-10-01 that the door's submit reads "Sign in", never
"Email me a link", and the r3 polish audit (SUMMARY items 6 and 7) found
every app saying its own thing on the same door. estilo feat/door-copy
(fbd14e7) now supplies the door's words itself, so fichas pins it and
stops overriding them.
- Pin estilo fbd14e7. chrome.SignIn lost Heading, AddressLabel, Help and
Submit, so the literal drops them: "Email me a link" and "Only people
this instance already knows can sign in." are gone.
- The sent state is head.Sent(signinPath): the door's heading, body and
"Use a different address", replacing fichas' own body.
- The tab title follows the state: "Sign in · Fichas" or "Check your
email · Fichas" (it read "Sign in · Fichas" on the sent page too).
- The email subject is chrome.EmailSubject("Fichas"), "Sign in to
Fichas", replacing "Your fichas sign-in link". The body still comes
from rastrillo until its Config.Body lands.
Fichas has no signed-out page (rastrillo's Signout redirects to
/signin), so there is nothing to change there. The keymail continuation
keeps its own copy.
Must not merge before estilo feat/door-copy lands; re-pin to estilo main
after its squash.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+81
−22
go.mod+1 −1go.sum+2 −2internal/fichas/auth.go+2 −1internal/fichas/handlers.go+18 −13internal/fichas/templates/signin.html+1 −1internal/fichastest/signin_flow_test.go+4 −0internal/fichastest/signin_test.go+53 −4
CI passed — run details
CI log
=== vet === go vet ./... go: downloading amadan.net/oficina/estilo.git v0.0.0-20261001075355-fbd14e759027 === fmt === === test === go test ./... ? amadan.net/oficina/fichas/cmd/fichas [no test files] ok amadan.net/oficina/fichas/internal/fichas 0.671s ? amadan.net/oficina/fichas/internal/fichas/icons [no test files] ok amadan.net/oficina/fichas/internal/fichas/seal 0.003s ok amadan.net/oficina/fichas/internal/fichastest 0.364s ? amadan.net/oficina/fichas/scripts/mockups [no test files] ? amadan.net/oficina/fichas/scripts/preview [no test files] ? amadan.net/oficina/fichas/scripts/writeestilo [no test files] === migration-check === go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree
diff --git a/go.mod b/go.mod| index a100ce5..560a11b 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -5,7 +5,7 @@ go 1.26 |
| toolchain go1.26.5 |
| require ( |
| - amadan.net/oficina/estilo.git v0.0.0-20260929231226-cce11010ece0 |
| + amadan.net/oficina/estilo.git v0.0.0-20261001075355-fbd14e759027 |
| github.com/carlosframework/rastrillo v0.23.0 |
| github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 |
| github.com/chromedp/chromedp v0.14.2 |
diff --git a/go.sum b/go.sum| index 8b81c46..f0d4388 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,5 +1,5 @@ |
| -amadan.net/oficina/estilo.git v0.0.0-20260929231226-cce11010ece0 h1:Qww+xGIYJaj+6TIX4Knr71ghxeYJjtgFm6WukX6NkGg= |
| -amadan.net/oficina/estilo.git v0.0.0-20260929231226-cce11010ece0/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= |
| github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= |
| github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= |
| github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w= |
diff --git a/internal/fichas/auth.go b/internal/fichas/auth.go| index b612cd8..4629897 100644 |
| --- a/internal/fichas/auth.go |
| +++ b/internal/fichas/auth.go |
| @@ -7,6 +7,7 @@ import ( |
| "net/http" |
| "strings" |
| + "amadan.net/oficina/estilo.git/chrome" |
| "github.com/carlosframework/rastrillo/auth" |
| "github.com/carlosframework/rastrillo/mail" |
| ) |
| @@ -144,7 +145,7 @@ func NewAuth(sqlDB *sql.DB, env AuthEnv, logger *slog.Logger, authorize func(str |
| Authorize: authorize, |
| SigninPath: signinPath, |
| SignedInPath: "/", |
| - Subject: "Your fichas sign-in link", |
| + Subject: chrome.EmailSubject("Fichas"), |
| Logger: logger, |
| }) |
| } |
diff --git a/internal/fichas/handlers.go b/internal/fichas/handlers.go| index 9ba49d7..c527e8a 100644 |
| --- a/internal/fichas/handlers.go |
| +++ b/internal/fichas/handlers.go |
| @@ -75,23 +75,15 @@ func (a *app) signin(w http.ResponseWriter, r *http.Request) { |
| head := doorHead(r) |
| if q.Get("sent") == "1" { |
| // The same page for an owner and a stranger (beginGuard), so it |
| - // promises nothing about the address. |
| - render(w, "signin", signinPage{Sent: &chrome.SignInNotice{ |
| - Icon: head.Icon, App: head.App, Sky: head.Sky, |
| - Heading: "Check your email", |
| - Body: "If that address can sign in here, a link is on its way. The link works once and expires.", |
| - Link: &chrome.Link{Href: signinPath, Label: "Use a different address"}, |
| - }}) |
| + // promises nothing about the address. The words are the door's. |
| + sent := head.Sent(signinPath) |
| + render(w, "signin", signinPage{Sent: &sent}) |
| return |
| } |
| form := chrome.SignIn{ |
| Icon: head.Icon, App: head.App, Sky: head.Sky, |
| - Heading: "Sign in", |
| - Error: signinError(q.Get("err")), |
| - Action: "/auth/begin", |
| - AddressLabel: "Email address", |
| - Help: "Only people this instance already knows can sign in.", |
| - Submit: "Email me a link", |
| + Error: signinError(q.Get("err")), |
| + Action: "/auth/begin", |
| } |
| // rastrillo sends a failed keymail round trip back with |
| // force=1&err=keymail so the page can offer plain email instead; |
| @@ -111,6 +103,19 @@ type signinPage struct { |
| Continue *signinContinue |
| } |
| +// Title is the tab title, the door's own for each state: "Sign in · |
| +// Fichas" or "Check your email · Fichas". The keymail continuation is |
| +// still signing in, so it keeps the form's. |
| +func (p signinPage) Title() string { |
| + switch { |
| + case p.Sent != nil: |
| + return p.Sent.Title() |
| + case p.Form != nil: |
| + return p.Form.Title() |
| + } |
| + return chrome.SignIn{App: p.Continue.Door.App}.Title() |
| +} |
| + |
| // signinContinue is the keymail continuation (auth_navigation.go): the |
| // door's head, and the validated authorize URL to navigate to. |
| type signinContinue struct { |
diff --git a/internal/fichas/templates/signin.html b/internal/fichas/templates/signin.html| index 7be29e5..35a6f56 100644 |
| --- a/internal/fichas/templates/signin.html |
| +++ b/internal/fichas/templates/signin.html |
| @@ -1,4 +1,4 @@ |
| -{{define "title"}}Sign in · Fichas{{end}} |
| +{{define "title"}}{{.Title}}{{end}} |
| {{/* The door's stylesheets, only here: no other page draws it. */}} |
| {{define "head"}} |
diff --git a/internal/fichastest/signin_flow_test.go b/internal/fichastest/signin_flow_test.go| index e075e19..f493850 100644 |
| --- a/internal/fichastest/signin_flow_test.go |
| +++ b/internal/fichastest/signin_flow_test.go |
| @@ -115,6 +115,10 @@ func TestASignInCompletesEndToEnd(t *testing.T) { |
| if !strings.HasPrefix(mail, TestOwner+"\n") { |
| t.Fatalf("the link went to the wrong address:\n%s", mail) |
| } |
| + // The subject is the door's: "Sign in to Fichas", the app capitalised. |
| + if !strings.HasPrefix(mail, TestOwner+"\nSign in to Fichas\n") { |
| + t.Errorf("the email's subject is not the door's \"Sign in to Fichas\":\n%s", mail) |
| + } |
| m := linkToken.FindStringSubmatch(mail) |
| if m == nil { |
| t.Fatalf("the email carries no sign-in link:\n%s", mail) |
diff --git a/internal/fichastest/signin_test.go b/internal/fichastest/signin_test.go| index 35fff07..fcb4736 100644 |
| --- a/internal/fichastest/signin_test.go |
| +++ b/internal/fichastest/signin_test.go |
| @@ -64,7 +64,7 @@ func TestSigninPageIsReachableWithoutASession(t *testing.T) { |
| if rec.Code != http.StatusOK { |
| t.Fatalf("GET /signin: status %d, want 200", rec.Code) |
| } |
| - if !strings.Contains(rec.Body.String(), "Email me a link") { |
| + if !strings.Contains(rec.Body.String(), `name="address"`) { |
| t.Errorf("the sign-in page has no form:\n%s", rec.Body.String()) |
| } |
| } |
| @@ -119,8 +119,8 @@ func TestSigninPageIsTheDoor(t *testing.T) { |
| `name="address"`, |
| "<h1>Sign in</h1>", |
| "Email address", |
| - "Only people this instance already knows can sign in.", |
| - "Email me a link", |
| + "We’ll email you a link that signs you in. It works once and expires in 15 minutes.", |
| + ">Sign in</button>", |
| `href="/static/chrome.`, |
| `href="/static/sky.`, |
| } { |
| @@ -141,7 +141,7 @@ func TestSigninPageIsTheDoor(t *testing.T) { |
| // The check-your-email state is the door too. |
| sent := get(t, h, "/signin?sent=1").Body.String() |
| - for _, want := range []string{"estilo-door", "<span>Fichas</span>", "Check your email", "If that address can sign in here"} { |
| + for _, want := range []string{"estilo-door", "<span>Fichas</span>", "Check your email", "If that address can sign in here, we’ve sent it a link. It works once and expires in 15 minutes."} { |
| if !strings.Contains(sent, want) { |
| t.Errorf("the sent page lacks %q:\n%s", want, sent) |
| } |
| @@ -151,6 +151,55 @@ func TestSigninPageIsTheDoor(t *testing.T) { |
| } |
| } |
| +// The door's words are the suite's, not fichas' own: estilo owns them |
| +// (chrome.English), so every app's door reads the same. The submit says |
| +// "Sign in" (Paul, 2026-10-01) and never "Email me a link", and each |
| +// state's tab title is "<Page> · Fichas". |
| +// |
| +// There is no signed-out page to pin: rastrillo's Signout redirects to |
| +// /signin, so signing out lands on the form. |
| +func TestTheDoorSpeaksTheSuitesWords(t *testing.T) { |
| + h := productionApp(t) |
| + title := regexp.MustCompile(`<title>([^<]*)</title>`) |
| + |
| + form := get(t, h, "/signin").Body.String() |
| + if got := title.FindStringSubmatch(form); got == nil || got[1] != "Sign in · Fichas" { |
| + t.Errorf("the sign-in page's title is %q, want %q", got, "Sign in · Fichas") |
| + } |
| + for _, want := range []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.", |
| + ">Sign in</button>", |
| + } { |
| + if !strings.Contains(form, want) { |
| + t.Errorf("the sign-in page lacks %q:\n%s", want, form) |
| + } |
| + } |
| + for _, gone := range []string{"Email me a link", "Only people this instance already knows", "Home"} { |
| + if strings.Contains(form, gone) { |
| + t.Errorf("the sign-in page still says %q", gone) |
| + } |
| + } |
| + |
| + sent := get(t, h, "/signin?sent=1").Body.String() |
| + if got := title.FindStringSubmatch(sent); got == nil || got[1] != "Check your email · Fichas" { |
| + t.Errorf("the sent page's title is %q, want %q", got, "Check your email · Fichas") |
| + } |
| + for _, want := range []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.", |
| + `href="/signin">Use a different address</a>`, |
| + } { |
| + if !strings.Contains(sent, want) { |
| + t.Errorf("the sent page lacks %q:\n%s", want, sent) |
| + } |
| + } |
| + if strings.Contains(sent, "a link is on its way") { |
| + t.Error("the sent page still carries fichas' own body") |
| + } |
| +} |
| + |
| // The door's sky draws its first frame from inline data: images, so the |
| // policy the page is actually served under must allow them. Built through |
| // rastrillo.Handler, the same assembly Serve uses, so the CSP is real. |