oficina / fichas Public

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>
Paul Campbell pushed by paul@keymail.dev e3082bc2407120e8d8543cde676f5d4ee7965640 parent 7af11bf
7 files changed, +81 −22
  • go.mod +1 −1
  • go.sum +2 −2
  • internal/fichas/auth.go +2 −1
  • internal/fichas/handlers.go +18 −13
  • internal/fichas/templates/signin.html +1 −1
  • internal/fichastest/signin_flow_test.go +4 −0
  • internal/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.