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>
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.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) |
| + } |
| + }) |
| + } |
| +} |