oficina / estilo Public

Merge branch 'feat/door-copy' into main

Merge feat/door-copy: one sign-in door copy set for every app

The button reads 'Sign in'; one help line, sent text and back link;
'<Page> · <App>' titles via chrome.Title; email subject and body from
the same words; the link lifetime is 15 minutes (keymaildev/signin
default, overridden nowhere). Reviewed by Claude Fable 5.1 (#19):
approve with nits. Landed as a merge commit so fbd14e7, which every
app's chore/door-copy pins, stays reachable.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 0b4d92c3dc1611159ac77136d61d1210ff3dfe6b parent cce1101parent fbd14e7
4 files changed, +320 −14
  • chrome/chrome.go +22 −10
  • chrome/chrome_test.go +4 −4
  • chrome/door.go +149 −0
  • chrome/door_test.go +145 −0

CI passed — run details

CI log
=== modcache ===
all modules verified
=== vet ===
CGO_ENABLED=0 go vet ./...
=== fmt ===
=== build ===
CGO_ENABLED=0 go build -buildvcs=false ./...
=== go-test ===
CGO_ENABLED=0 go test -count=1 ./...
ok  	amadan.net/oficina/estilo.git/base	0.005s
ok  	amadan.net/oficina/estilo.git/brand	0.008s
ok  	amadan.net/oficina/estilo.git/busy	0.016s
ok  	amadan.net/oficina/estilo.git/callui	0.021s
ok  	amadan.net/oficina/estilo.git/chrome	0.350s
?   	amadan.net/oficina/estilo.git/cmd/oficina-notices	[no test files]
ok  	amadan.net/oficina/estilo.git/credits	0.008s
ok  	amadan.net/oficina/estilo.git/credits/catalog	0.006s
ok  	amadan.net/oficina/estilo.git/homeconnect	0.006s
ok  	amadan.net/oficina/estilo.git/internal/lint	0.006s
?   	amadan.net/oficina/estilo.git/internal/tokencheck	[no test files]
ok  	amadan.net/oficina/estilo.git/menu	0.014s
ok  	amadan.net/oficina/estilo.git/savestatus	0.111s
ok  	amadan.net/oficina/estilo.git/sky	0.030s
ok  	amadan.net/oficina/estilo.git/theme	0.011s
diff --git a/chrome/chrome.go b/chrome/chrome.go
index 895441e..4253bdc 100644
--- a/chrome/chrome.go
+++ b/chrome/chrome.go
@@ -196,28 +196,40 @@ type Hidden struct {
// SignIn is estilo-signin's data. Error is shown in a negative callout
// above the form; AddressError in the field's own error slot.
+//
+// The door's words are its own (DoorCopy): the app passes its name, mark,
+// sky and destinations, and Copy only for a locale other than English.
type SignIn struct {
- Icon, Heading, Lede string
+ Icon, Lede string
// App is the product's name, beside its mark at the head of the card
// ("Calendar"); Sky is the sky behind it, sky.For(<the instance's
// host>). Both optional. A Sky needs sky.css linked, sky.Handler
// mounted at sky.Prefix and img-src data: allowed (the template).
- App string
- Sky sky.View
- Error string
- Action, Return string
- Hidden []Hidden
- AddressLabel, Address, Help, AddressError string
- Submit string
- OpenSource *Link
+ App string
+ Sky sky.View
+ Error string
+ Action, Return string
+ Hidden []Hidden
+ Address, AddressError string
+ OpenSource *Link
+ // Copy replaces the door's English (nil) with another locale's set.
+ Copy *DoorCopy
}
-// SignInNotice is the data of estilo-signin-sent and estilo-signed-out.
+// SignInNotice is the data of estilo-signin-sent and estilo-signed-out,
+// and of any other notice drawn as the door (the Keymail hand-off, the
+// hub's account pages). For the door's own two, build it with Sent or
+// SignedOut, which supply the heading, body and link label; set Heading,
+// Body and Link directly only for a notice of the app's own.
type SignInNotice struct {
Icon, Heading, Body string
App string
Sky sky.View
Link *Link
+ // Copy replaces the door's English (nil) with another locale's set.
+ Copy *DoorCopy
+ // page is the tab title's page, set by Sent and SignedOut.
+ page string
}
// Initials is the avatar's text: the first letter of name, or else of
diff --git a/chrome/chrome_test.go b/chrome/chrome_test.go
index 61e9a91..61c4c3d 100644
--- a/chrome/chrome_test.go
+++ b/chrome/chrome_test.go
@@ -321,7 +321,7 @@ func TestSignInPages(t *testing.T) {
for locale, label := range locales {
t.Run(locale, func(t *testing.T) {
esc := template.HTMLEscapeString
- html := render(t, "estilo-signin", SignIn{Icon: "/i.svg", Heading: label + "-h", Lede: label + "-lede", Error: label + "-err", Action: "/signin", Return: "/d/abc", Hidden: []Hidden{{"force", "1"}}, AddressLabel: label + "-addr", Help: label + "-help", Submit: label + "-submit", OpenSource: &Link{Href: "/credits", Label: label + "-os"}})
+ html := render(t, "estilo-signin", SignIn{Icon: "/i.svg", Copy: &DoorCopy{Heading: label + "-h", AddressLabel: label + "-addr", Help: label + "-help", Submit: label + "-submit"}, Lede: label + "-lede", Error: label + "-err", Action: "/signin", Return: "/d/abc", Hidden: []Hidden{{"force", "1"}}, OpenSource: &Link{Href: "/credits", Label: label + "-os"}})
for _, want := range []string{"<h1>" + esc(label) + "-h</h1>", esc(label) + "-lede", `rst-tone="negative" role="alert"`, esc(label) + "-err", `action="/signin" novalidate`, `name="return_to" value="/d/abc"`, `name="force" value="1"`, `type="email"`, `autocomplete="email"`, esc(label) + "-addr", esc(label) + "-help", esc(label) + "-submit", esc(label) + "-os"} {
if !strings.Contains(html, want) {
t.Fatalf("missing %q in %s", want, html)
@@ -338,7 +338,7 @@ func TestSignInPages(t *testing.T) {
})
}
// An address error belongs in the field, not a callout above it.
- html := render(t, "estilo-signin", SignIn{Icon: "/i.svg", Heading: "h", Action: "/signin", AddressLabel: "a", Submit: "s", AddressError: "bad address"})
+ html := render(t, "estilo-signin", SignIn{Icon: "/i.svg", Action: "/signin", AddressError: "bad address"})
if strings.Contains(html, "rst-callout") || !strings.Contains(html, `rst-field-error id="estilo-signin-address-error" role="alert">bad address`) || !strings.Contains(html, `aria-invalid="true"`) {
t.Fatal("address error not in the field slot", html)
}
@@ -455,7 +455,7 @@ func TestScriptBehaviour(t *testing.T) {
// package's own, or the two would drift.
func TestSignInIsTheOficinaDoor(t *testing.T) {
v := sky.For("calendar.oficina.works")
- html := render(t, "estilo-signin", SignIn{Icon: "/i.svg", App: "Calendar", Sky: v, Heading: "Sign in", Action: "/signin", AddressLabel: "Email address", Submit: "Email me a link"})
+ html := render(t, "estilo-signin", SignIn{Icon: "/i.svg", App: "Calendar", Sky: v, Action: "/signin"})
var want bytes.Buffer
if err := template.Must(template.ParseFS(sky.Templates(), "templates/*.html")).ExecuteTemplate(&want, "estilo-sky", v); err != nil {
t.Fatal(err)
@@ -475,7 +475,7 @@ func TestSignInIsTheOficinaDoor(t *testing.T) {
}
}
// No sky given, no sky drawn: the card still stands.
- if h := render(t, "estilo-signin", SignIn{Icon: "/i.svg", Heading: "h", Action: "/s", AddressLabel: "a", Submit: "s"}); strings.Contains(h, "estilo-sky-layer") {
+ if h := render(t, "estilo-signin", SignIn{Icon: "/i.svg", Action: "/s"}); strings.Contains(h, "estilo-sky-layer") {
t.Error("a sign-in with no Sky drew one")
}
}
diff --git a/chrome/door.go b/chrome/door.go
new file mode 100644
index 0000000..d136e2d
--- /dev/null
+++ b/chrome/door.go
@@ -0,0 +1,149 @@
+package chrome
+
+import (
+ "fmt"
+ "strings"
+ "time"
+)
+
+// LinkLifetime is how long an emailed sign-in link works. It is
+// keymaildev/signin's LinkTTL default, which every sign-in in the suite
+// inherits: rastrillo's auth, Home's own flow and the keymail path all
+// leave LinkTTL unset (checked 2026-10-01 at signin v0.1.1, rastrillo
+// main 2fafa12 and v0.23.0, and Home 7a0f19c). The copy below says the
+// number, so a sign-in that changes its lifetime must change this too, or
+// the door and the email tell people something false.
+const LinkLifetime = 15 * time.Minute
+
+// DoorCopy is the door's words: the sign-in form, the check-your-email
+// state, the signed-out page and the sign-in email. English is the
+// suite's one copy set; a locale replaces the whole set through
+// SignIn.Copy and SignInNotice.Copy, never a word at a time, so no app
+// can drift from the others by overriding one line.
+type DoorCopy struct {
+ // The form: heading (also the tab title's page), address label, the
+ // one help line under the field, and the submit.
+ Heading, AddressLabel, Help, Submit string
+ // The sent state, which replaces the form: heading (and tab title),
+ // body, and the link back to the form.
+ SentHeading, SentBody, Again string
+ // The signed-out page: the tab title's page, the heading, the body
+ // (a format with one %s, the app's name) and the Sign in button.
+ SignedOutPage, SignedOutHeading, SignedOutBody, SignIn string
+ // The sign-in email: subject and body formats. The subject takes the
+ // app's name; the body the app's name, then the link.
+ EmailSubject, EmailBody string
+}
+
+// minutes is LinkLifetime in words, so the number lives in one place.
+var minutes = fmt.Sprintf("%d minutes", int(LinkLifetime/time.Minute))
+
+// English is the door's copy set (spec §9.2). The submit is "Sign in":
+// Paul, 2026-10-01, ruled out "Email me a link". The hub is never named
+// ("Home"); the suite is Oficina (Paul, 2026-09-23).
+var English = DoorCopy{
+ Heading: "Sign in",
+ AddressLabel: "Email address",
+ Help: "We’ll email you a link that signs you in. It works once and expires in " + minutes + ".",
+ Submit: "Sign in",
+ SentHeading: "Check your email",
+ SentBody: "If that address can sign in here, we’ve sent it a link. It works once and expires in " + minutes + ".",
+ Again: "Use a different address",
+ SignedOutPage: "Signed out",
+ SignedOutHeading: "You’re signed out",
+ SignedOutBody: "You’ve signed out of %s on this device.",
+ SignIn: "Sign in",
+ EmailSubject: "Sign in to %s",
+ EmailBody: "Here’s your link to sign in to %s:\n\n%s\n\nIt works once and expires in " + minutes + ". If you didn’t ask for it, you can ignore this email.",
+}
+
+// signedOutBody names the app; with none it names the suite, as the hub's
+// own signed-out page does.
+func (c DoorCopy) signedOutBody(app string) string {
+ if app == "" {
+ app = "Oficina"
+ }
+ return fmt.Sprintf(c.SignedOutBody, app)
+}
+
+func copyOf(c *DoorCopy) DoorCopy {
+ if c == nil {
+ return English
+ }
+ return *c
+}
+
+// Title is a page's tab title: "<Page> · <App>" (spec §10), or whichever
+// half is set when one is empty.
+func Title(page, app string) string {
+ page, app = strings.TrimSpace(page), strings.TrimSpace(app)
+ switch {
+ case page == "":
+ return app
+ case app == "":
+ return page
+ }
+ return page + " · " + app
+}
+
+// The form's words, read by estilo-signin.
+func (s SignIn) Heading() string { return copyOf(s.Copy).Heading }
+func (s SignIn) AddressLabel() string { return copyOf(s.Copy).AddressLabel }
+func (s SignIn) Help() string { return copyOf(s.Copy).Help }
+func (s SignIn) Submit() string { return copyOf(s.Copy).Submit }
+
+// Page is the sign-in page's part of the tab title ("Sign in"), for a
+// layout that appends the app's name itself; Title is the whole title.
+func (s SignIn) Page() string { return s.Heading() }
+func (s SignIn) Title() string { return Title(s.Page(), s.App) }
+
+// Sent is this door's check-your-email state: the door's heading and
+// body, and "Use a different address" linking to again (the form,
+// keeping the return; empty drops the link).
+func (n SignInNotice) Sent(again string) SignInNotice {
+ c := copyOf(n.Copy)
+ n.Heading, n.Body, n.page, n.Link = c.SentHeading, c.SentBody, c.SentHeading, nil
+ if again != "" {
+ n.Link = &Link{Href: again, Label: c.Again}
+ }
+ return n
+}
+
+// SignedOut is this door's signed-out page: "You’re signed out", the
+// sentence naming the app, and the primary Sign in linking to signin.
+func (n SignInNotice) SignedOut(signin string) SignInNotice {
+ c := copyOf(n.Copy)
+ n.Heading, n.Body, n.page, n.Link = c.SignedOutHeading, c.signedOutBody(n.App), c.SignedOutPage, nil
+ if signin != "" {
+ n.Link = &Link{Href: signin, Label: c.SignIn}
+ }
+ return n
+}
+
+// Page is the notice's part of the tab title: the door's own for Sent and
+// SignedOut ("Check your email", "Signed out"), otherwise the heading.
+func (n SignInNotice) Page() string {
+ if n.page != "" {
+ return n.page
+ }
+ return n.Heading
+}
+
+// Title is the notice's whole tab title, "<Page> · <App>".
+func (n SignInNotice) Title() string { return Title(n.Page(), n.App) }
+
+// SignInEmail is the sign-in email for app: subject and body around link,
+// in English.
+func SignInEmail(app, link string) (subject, body string) {
+ return EmailSubject(app), EmailBody(app)(link)
+}
+
+// EmailSubject is the sign-in email's subject, "Sign in to <App>": what
+// rastrillo's auth.Config.Subject takes.
+func EmailSubject(app string) string { return fmt.Sprintf(English.EmailSubject, app) }
+
+// EmailBody renders the sign-in email's body around the link: the shape
+// keymaildev/signin's Flow.Body takes.
+func EmailBody(app string) func(link string) string {
+ return func(link string) string { return fmt.Sprintf(English.EmailBody, app, link) }
+}
diff --git a/chrome/door_test.go b/chrome/door_test.go
new file mode 100644
index 0000000..5a442ab
--- /dev/null
+++ b/chrome/door_test.go
@@ -0,0 +1,145 @@
+package chrome
+
+import (
+ "html/template"
+ "strings"
+ "testing"
+ "time"
+)
+
+// The door's copy is the suite's one copy set (spec §9.2, with Paul's
+// 2026-10-01 ruling that the submit reads "Sign in"). Pinned word for
+// word: a change here is a copy decision, made on purpose, in one place.
+func TestDoorCopyIsPinned(t *testing.T) {
+ if LinkLifetime != 15*time.Minute {
+ t.Fatalf("LinkLifetime = %v; the copy below says 15 minutes", LinkLifetime)
+ }
+ for _, c := range []struct{ name, got, want string }{
+ {"Heading", English.Heading, "Sign in"},
+ {"AddressLabel", English.AddressLabel, "Email address"},
+ {"Help", English.Help, "We’ll email you a link that signs you in. It works once and expires in 15 minutes."},
+ {"Submit", English.Submit, "Sign in"},
+ {"SentHeading", English.SentHeading, "Check your email"},
+ {"SentBody", English.SentBody, "If that address can sign in here, we’ve sent it a link. It works once and expires in 15 minutes."},
+ {"Again", English.Again, "Use a different address"},
+ {"SignedOutPage", English.SignedOutPage, "Signed out"},
+ {"SignedOutHeading", English.SignedOutHeading, "You’re signed out"},
+ {"SignedOutBody", English.signedOutBody("Calls"), "You’ve signed out of Calls on this device."},
+ {"SignIn", English.SignIn, "Sign in"},
+ } {
+ if c.got != c.want {
+ t.Errorf("%s = %q, want %q", c.name, c.got, c.want)
+ }
+ }
+ for _, retired := range []string{"Email me a link", "Continue", "expires shortly", "on its way", "Back to sign in", "Send another link", "Use a different email address", "You are signed out", "Home"} {
+ for _, s := range []string{English.Heading, English.Help, English.Submit, English.SentHeading, English.SentBody, English.Again, English.SignedOutHeading, English.signedOutBody("Docs"), English.SignIn} {
+ if strings.Contains(s, retired) {
+ t.Errorf("%q carries retired copy %q", s, retired)
+ }
+ }
+ }
+}
+
+// Every door page's tab title is "<Page> · <App>" (spec §10).
+func TestDoorTitles(t *testing.T) {
+ for _, c := range []struct{ got, want string }{
+ {Title("Sign in", "Docs"), "Sign in · Docs"},
+ {Title("Sign in", ""), "Sign in"},
+ {Title("", "Docs"), "Docs"},
+ {SignIn{App: "Memoria"}.Title(), "Sign in · Memoria"},
+ {SignIn{App: "Memoria"}.Page(), "Sign in"},
+ {SignInNotice{App: "Calendar"}.Sent("/signin").Title(), "Check your email · Calendar"},
+ {SignInNotice{App: "Calendar"}.Sent("/signin").Page(), "Check your email"},
+ {SignInNotice{App: "Calls"}.SignedOut("/signin").Title(), "Signed out · Calls"},
+ {SignInNotice{App: "Calls"}.SignedOut("/signin").Page(), "Signed out"},
+ // A notice that is not one of the door's own is titled by its heading.
+ {SignInNotice{App: "Sheets", Heading: "Continue signing in"}.Title(), "Continue signing in · Sheets"},
+ } {
+ if c.got != c.want {
+ t.Errorf("title %q, want %q", c.got, c.want)
+ }
+ }
+}
+
+// The templates draw the door's copy when the app supplies none: the app
+// passes its name, mark, sky and destinations, and nothing else.
+func TestTheDoorDrawsItsOwnCopy(t *testing.T) {
+ html := render(t, "estilo-signin", SignIn{Icon: "/i.svg", App: "Calls", Action: "/signin"})
+ for _, want := range []string{
+ "<h1>Sign in</h1>",
+ ">Email address</label>",
+ "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>`,
+ } {
+ if !strings.Contains(html, want) {
+ t.Errorf("sign-in lacks %q:\n%s", want, html)
+ }
+ }
+ if strings.Contains(html, "Email me a link") {
+ t.Error("the door's submit must never read “Email me a link”")
+ }
+
+ html = render(t, "estilo-signin-sent", SignInNotice{Icon: "/i.svg", App: "Calls"}.Sent("/signin?return_to=%2Fm%2Fabc"))
+ for _, want := range []string{
+ "<h1>Check your email</h1>",
+ `<p estilo-signin-body role="status">If that address can sign in here, we’ve sent it a link. It works once and expires in 15 minutes.</p>`,
+ `<a href="/signin?return_to=%2Fm%2Fabc">Use a different address</a>`,
+ } {
+ if !strings.Contains(html, want) {
+ t.Errorf("sent lacks %q:\n%s", want, html)
+ }
+ }
+
+ html = render(t, "estilo-signed-out", SignInNotice{Icon: "/i.svg", App: "Calls"}.SignedOut("/signin"))
+ for _, want := range []string{
+ "<h1>You’re signed out</h1>",
+ "<p estilo-signin-body>You’ve signed out of Calls on this device.</p>",
+ `<a rst-btn="primary" href="/signin">Sign in</a>`,
+ } {
+ if !strings.Contains(html, want) {
+ t.Errorf("signed-out lacks %q:\n%s", want, html)
+ }
+ }
+}
+
+// The sign-in email names the app and says the true lifetime, the same
+// words as the door (spec §9.2).
+func TestSignInEmail(t *testing.T) {
+ subject, body := SignInEmail("Docs", "https://docs.example/auth/verify?token=x")
+ if subject != "Sign in to Docs" {
+ t.Errorf("subject %q", subject)
+ }
+ want := "Here’s your link to sign in to Docs:\n\nhttps://docs.example/auth/verify?token=x\n\nIt works once and expires in 15 minutes. If you didn’t ask for it, you can ignore this email."
+ if body != want {
+ t.Errorf("body %q\nwant %q", body, want)
+ }
+ if got := EmailBody("Docs")("L"); !strings.Contains(got, "\n\nL\n\n") {
+ t.Errorf("EmailBody does not place the link: %q", got)
+ }
+}
+
+// A locale other than English replaces every word through Copy.
+func TestDoorCopyIsReplaceablePerLocale(t *testing.T) {
+ for locale, label := range locales {
+ t.Run(locale, func(t *testing.T) {
+ esc := template.HTMLEscapeString
+ c := &DoorCopy{Heading: label + "-h", AddressLabel: label + "-addr", Help: label + "-help", Submit: label + "-submit", SentHeading: label + "-sent", SentBody: label + "-sentbody", Again: label + "-again", SignedOutPage: label + "-outpage", SignedOutHeading: label + "-out", SignedOutBody: label + " %s-outbody", SignIn: label + "-in"}
+ html := render(t, "estilo-signin", SignIn{Icon: "/i.svg", App: "Docs", Copy: c, Action: "/signin"})
+ for _, want := range []string{"<h1>" + esc(label) + "-h</h1>", esc(label) + "-addr", esc(label) + "-help", esc(label) + "-submit"} {
+ if !strings.Contains(html, want) {
+ t.Fatalf("missing %q in %s", want, html)
+ }
+ }
+ n := SignInNotice{Icon: "/i.svg", App: "Docs", Copy: c}
+ if h := render(t, "estilo-signin-sent", n.Sent("/signin")); !strings.Contains(h, esc(label)+"-sentbody") || !strings.Contains(h, esc(label)+"-again") {
+ t.Fatal("sent", h)
+ }
+ if h := render(t, "estilo-signed-out", n.SignedOut("/signin")); !strings.Contains(h, esc(label)+" Docs-outbody") || !strings.Contains(h, esc(label)+"-in") {
+ t.Fatal("signed out", h)
+ }
+ if got := n.SignedOut("/signin").Title(); got != label+"-outpage · Docs" {
+ t.Fatalf("title %q", got)
+ }
+ })
+ }
+}