The door owns its copy: one set, "Sign in", and titles
Five helper lines, five sent texts, four back links and three title shapes had grown across the apps because the door carried no words of its own: every app supplied its own, and each drifted (Calls: "A sign in link should be on its way with a link to click"; Calendar and the email: "expires shortly", against the other doors' "15 minutes"). The door now owns one set (DoorCopy, English per spec §9.2), with Paul's 2026-10-01 ruling that the submit reads "Sign in", never "Email me a link". SignIn loses its Heading, AddressLabel, Help and Submit fields, so an app still overriding them fails to compile rather than drifting quietly; Copy replaces the whole set for another locale, never a line at a time. SignInNotice stays generic (connect's pages and the Keymail hand-off use it) and gains Sent and SignedOut, which fill the door's own heading, body and link. Title(page, app) is "<Page> · <App>" (spec §10); SignIn and SignInNotice expose Page and Title so every app's tab says the same. The lifetime is LinkLifetime, 15 minutes: keymaildev/signin's LinkTTL default, which rastrillo's auth, Home and the keymail path all inherit unchanged, so one number is true everywhere. SignInEmail, EmailSubject and EmailBody give the email the same words and the app's name.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+320
−14
chrome/chrome.go+22 −10chrome/chrome_test.go+4 −4chrome/door.go+149 −0chrome/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.004s ok amadan.net/oficina/estilo.git/brand 0.003s ok amadan.net/oficina/estilo.git/busy 0.003s ok amadan.net/oficina/estilo.git/callui 0.004s ok amadan.net/oficina/estilo.git/chrome 0.221s ? amadan.net/oficina/estilo.git/cmd/oficina-notices [no test files] ok amadan.net/oficina/estilo.git/credits 0.006s ok amadan.net/oficina/estilo.git/credits/catalog 0.007s ok amadan.net/oficina/estilo.git/homeconnect 0.008s ok amadan.net/oficina/estilo.git/internal/lint 0.005s ? amadan.net/oficina/estilo.git/internal/tokencheck [no test files] ok amadan.net/oficina/estilo.git/menu 0.009s ok amadan.net/oficina/estilo.git/savestatus 0.077s ok amadan.net/oficina/estilo.git/sky 0.023s ok amadan.net/oficina/estilo.git/theme 0.007s
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) |
| + } |
| + }) |
| + } |
| +} |