Speak the suite's sign-in door copy in Calendar
The door's button reads 'Sign in'; the old lede, 'There is no password to forget', 'expires shortly' and 'Send another link' are retired; titles read '<Page> · Calendar'; the email subject is 'Sign in to Calendar'. Reviewed by GPT-6-Astra via Codex (#24). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Squashed from chore/door-copy into main.
8 files changed,
+71
−57
go.mod+1 −1go.sum+2 −2internal/calendar/auth.go+2 −1internal/calendar/home.go+5 −5internal/calendar/home_door_test.go+8 −4internal/calendar/signin.go+26 −37internal/calendartest/signed_out_test.go+3 −1internal/calendartest/signin_door_test.go+24 −6
CI failed — run details
CI log
=== vet === go vet ./... go: downloading amadan.net/oficina/estilo.git v0.0.0-20261001084202-0b4d92c3dc16 internal/calendar/auth.go:6:2: amadan.net/oficina/estilo.git@v0.0.0-20261001084202-0b4d92c3dc16: invalid version: git fetch --unshallow -f --end-of-options origin in /home/paulca/.local/state/amadan/runner/repos/oficina__calendar/cache/go-mod/cache/vcs/bf4984e7a537af013cffcd0a654026b503a4e1566cddf740e3c37d2f6ae937e4: exit status 128: error: RPC failed; HTTP 429 curl 22 The requested URL returned error: 429 fatal: expected 'acknowledgments' internal/calendar/credits.go:8:2: amadan.net/oficina/estilo.git@v0.0.0-20261001084202-0b4d92c3dc16: invalid version: git fetch --unshallow -f --end-of-options origin in /home/paulca/.local/state/amadan/runner/repos/oficina__calendar/cache/go-mod/cache/vcs/bf4984e7a537af013cffcd0a654026b503a4e1566cddf740e3c37d2f6ae937e4: exit status 128: error: RPC failed; HTTP 429 curl 22 The requested URL returned error: 429 fatal: expected 'acknowledgments' internal/calendar/chrome_calendar.go:9:2: amadan.net/oficina/estilo.git@v0.0.0-20261001084202-0b4d92c3dc16: invalid version: git fetch --unshallow -f --end-of-options origin in /home/paulca/.local/state/amadan/runner/repos/oficina__calendar/cache/go-mod/cache/vcs/bf4984e7a537af013cffcd0a654026b503a4e1566cddf740e3c37d2f6ae937e4: exit status 128: error: RPC failed; HTTP 429 curl 22 The requested URL returned error: 429 fatal: expected 'acknowledgments' internal/calendar/routes.go:4:2: amadan.net/oficina/estilo.git@v0.0.0-20261001084202-0b4d92c3dc16: invalid version: git fetch --unshallow -f --end-of-options origin in /home/paulca/.local/state/amadan/runner/repos/oficina__calendar/cache/go-mod/cache/vcs/bf4984e7a537af013cffcd0a654026b503a4e1566cddf740e3c37d2f6ae937e4: exit status 128: error: RPC failed; HTTP 429 curl 22 The requested URL returned error: 429 fatal: expected 'acknowledgments' internal/calendar/routes.go:5:2: amadan.net/oficina/estilo.git@v0.0.0-20261001084202-0b4d92c3dc16: invalid version: git fetch --unshallow -f --end-of-options origin in /home/paulca/.local/state/amadan/runner/repos/oficina__calendar/cache/go-mod/cache/vcs/bf4984e7a537af013cffcd0a654026b503a4e1566cddf740e3c37d2f6ae937e4: exit status 128: error: RPC failed; HTTP 429 curl 22 The requested URL returned error: 429 fatal: expected 'acknowledgments' ../../cache/go-mod/amadan.net/oficina/home/connect@v0.0.0-20260923190447-01166fcfc0a9/oauth.go:4:2: amadan.net/oficina/estilo.git@v0.0.0-20261001084202-0b4d92c3dc16: invalid version: git fetch --unshallow -f --end-of-options origin in /home/paulca/.local/state/amadan/runner/repos/oficina__calendar/cache/go-mod/cache/vcs/bf4984e7a537af013cffcd0a654026b503a4e1566cddf740e3c37d2f6ae937e4: exit status 128: error: RPC failed; HTTP 429 curl 22 The requested URL returned error: 429 fatal: expected 'acknowledgments' scripts/writeestilo/main.go:12:2: amadan.net/oficina/estilo.git@v0.0.0-20261001084202-0b4d92c3dc16: invalid version: git fetch --unshallow -f --end-of-options origin in /home/paulca/.local/state/amadan/runner/repos/oficina__calendar/cache/go-mod/cache/vcs/bf4984e7a537af013cffcd0a654026b503a4e1566cddf740e3c37d2f6ae937e4: exit status 128: error: RPC failed; HTTP 429 curl 22 The requested URL returned error: 429 fatal: expected 'acknowledgments' scripts/writeestilo/main.go:13:2: amadan.net/oficina/estilo.git@v0.0.0-20261001084202-0b4d92c3dc16: invalid version: git fetch --unshallow -f --end-of-options origin in /home/paulca/.local/state/amadan/runner/repos/oficina__calendar/cache/go-mod/cache/vcs/bf4984e7a537af013cffcd0a654026b503a4e1566cddf740e3c37d2f6ae937e4: exit status 128: error: RPC failed; HTTP 429 curl 22 The requested URL returned error: 429 fatal: expected 'acknowledgments' make: *** [Makefile:88: vet] Error 1 amadan: step vet failed: exit status 2 amadan: job failed at step vet — its output is above, not at the tail of this log
diff --git a/go.mod b/go.mod| index da777bc..e95c8ff 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -14,7 +14,7 @@ require ( |
| gorm.io/gorm v1.31.2 |
| ) |
| -require amadan.net/oficina/estilo.git v0.0.0-20260929231226-cce11010ece0 |
| +require amadan.net/oficina/estilo.git v0.0.0-20261001084202-0b4d92c3dc16 |
| require ( |
| github.com/BurntSushi/toml v1.6.0 // indirect |
diff --git a/go.sum b/go.sum| index 2f15ceb..584fdf4 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-20261001084202-0b4d92c3dc16 h1:F5v3x25dEj1rJNnBm71G7shN+fM+T2actUlaGPvoeH8= |
| +amadan.net/oficina/estilo.git v0.0.0-20261001084202-0b4d92c3dc16/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9 h1:K/PaOVdQqRMwifPD6ipiwLeGR5nbFSxm9FxjrDJlZcg= |
| amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM= |
diff --git a/internal/calendar/auth.go b/internal/calendar/auth.go| index cbb91b3..1188633 100644 |
| --- a/internal/calendar/auth.go |
| +++ b/internal/calendar/auth.go |
| @@ -3,6 +3,7 @@ |
| package calendar |
| import ( |
| + "amadan.net/oficina/estilo.git/chrome" |
| "amadan.net/oficina/home/connect" |
| "context" |
| "database/sql" |
| @@ -266,7 +267,7 @@ func newAuthPlugin(origin string, writer *sql.DB, instanceKey string, mailer mai |
| InstanceKey: instanceKey, |
| Mailer: mailer, |
| Authorize: list.allows, |
| - Subject: "Your calendar sign-in link", |
| + Subject: chrome.EmailSubject("Calendar"), |
| Logger: logger, |
| }) |
| } |
diff --git a/internal/calendar/home.go b/internal/calendar/home.go| index 7921c62..4f9b8d7 100644 |
| --- a/internal/calendar/home.go |
| +++ b/internal/calendar/home.go |
| @@ -7,7 +7,6 @@ import ( |
| "errors" |
| "net/http" |
| - "amadan.net/oficina/estilo.git/chrome" |
| "amadan.net/oficina/home/connect" |
| "amadan.net/oficina/home/connect/linkstore" |
| "github.com/carlosframework/rastrillo/csrf" |
| @@ -59,13 +58,14 @@ func (a *App) newHome(settings *connect.DecodedCredentials, list allowlist) (*ho |
| } |
| page := func(w http.ResponseWriter, r *http.Request, state, label string, status int) { |
| data := connect.Page(state, label) |
| - // Where sign-out lands is the Oficina door, with Home's own words. |
| - tree, view := pages["home"], any(data) |
| + // Where sign-out lands is the Oficina door, in the door's words. |
| + tree, title, view := pages["home"], data.Title, any(data) |
| if state == "signedout" { |
| - tree, view = pages["signin"], signedOutDoor(r, data.Title, data.Body, chrome.Link{Href: data.Action, Label: data.ActionLabel}) |
| + notice := signedOutDoor(r, data.Action) |
| + tree, title, view = pages["signin"], notice.Page(), signinData{SignedOut: ¬ice} |
| } |
| var buf bytes.Buffer |
| - if err := tree.ExecuteTemplate(&buf, "layout", a.h.page(r, data.Title, view)); err != nil { |
| + if err := tree.ExecuteTemplate(&buf, "layout", a.h.page(r, title, view)); err != nil { |
| a.h.errorPage(w, r, http.StatusInternalServerError, "") |
| return |
| } |
diff --git a/internal/calendar/home_door_test.go b/internal/calendar/home_door_test.go| index f4b48e5..34723cf 100644 |
| --- a/internal/calendar/home_door_test.go |
| +++ b/internal/calendar/home_door_test.go |
| @@ -7,13 +7,14 @@ import ( |
| // With Home on, the local form (/signin/local) and where sign-out lands |
| // (/signed-out) are the Oficina door too. The form still posts to |
| -// /signin; the signed-out card keeps Home's own words and link. |
| +// /signin; the signed-out card is the door's own (never naming Home), |
| +// linking where connect says to sign in. |
| func TestHomeSignInPagesAreTheDoor(t *testing.T) { |
| f := newHomeRouterFixture(t) |
| for path, want := range map[string][]string{ |
| - "/signin/local": {"estilo-door", "<span>Calendar</span>", `action="/signin"`, `name="address"`}, |
| - "/signin/local?sent=1": {"estilo-door", "Check your email", `href="/signin/local"`}, |
| - "/signed-out": {"estilo-door", "<span>Calendar</span>", "Signed out", "You can sign in again with Home.", `href="/signin">Sign in with Home`}, |
| + "/signin/local": {"estilo-door", "<title>Sign in · Calendar</title>", "<span>Calendar</span>", `action="/signin"`, `name="address"`, `type="submit">Sign in</button>`}, |
| + "/signin/local?sent=1": {"estilo-door", "<title>Check your email · Calendar</title>", "Check your email", `href="/signin/local"`, "Use a different address"}, |
| + "/signed-out": {"estilo-door", "<title>Signed out · Calendar</title>", "<span>Calendar</span>", "You’re signed out", "You’ve signed out of Calendar on this device.", `href="/signin">Sign in</a>`}, |
| } { |
| w := f.do("GET", path, "") |
| if w.Code != 200 { |
| @@ -25,6 +26,9 @@ func TestHomeSignInPagesAreTheDoor(t *testing.T) { |
| } |
| } |
| } |
| + if body := f.do("GET", "/signed-out", "").Body.String(); strings.Contains(body, "Home") { |
| + t.Errorf("/signed-out names Home to people:\n%s", body) |
| + } |
| if got := f.do("GET", "/signed-out", "").Header().Get("Cache-Control"); got != "no-store" { |
| t.Errorf("/signed-out Cache-Control = %q, want no-store", got) |
| } |
diff --git a/internal/calendar/signin.go b/internal/calendar/signin.go| index 1a2a350..5bee915 100644 |
| --- a/internal/calendar/signin.go |
| +++ b/internal/calendar/signin.go |
| @@ -9,8 +9,8 @@ import ( |
| // The sign-in pages are the Oficina door (estilo chrome): one card on the |
| // suite's sky, Calendar's own mark and name at its head, in Calendar's |
| -// accent. Only the drawing changed; every outcome, redirect and sentence |
| -// below is the one the old page had. |
| +// accent. The door's words are estilo's (chrome.English), not Calendar's; |
| +// every outcome and redirect below is the one the old page had. |
| // signin serves GET /signin — the form, and the landing spot for every |
| // outcome the auth plugin's own handlers redirect back to. The plugin |
| @@ -31,16 +31,11 @@ import ( |
| func (h *handlers) signin(w http.ResponseWriter, r *http.Request) { |
| q := r.URL.Query() |
| form := &chrome.SignIn{ |
| - Icon: assets.Path("static/brand/icon.svg"), |
| - App: "Calendar", |
| - Sky: sky.For(r.Host), |
| - Heading: "Sign in", |
| - Lede: "A calendar for one team, on one instance.", |
| - Action: "/signin", |
| - AddressLabel: "Email address", |
| - Help: "We email you a link. There is no password to forget.", |
| - Submit: "Sign in", // Paul, 2026-10-01 |
| - OpenSource: &chrome.Link{Href: "/help/open-source", Label: "Open source"}, |
| + Icon: assets.Path("static/brand/icon.svg"), |
| + App: "Calendar", |
| + Sky: sky.For(r.Host), |
| + Action: "/signin", |
| + OpenSource: &chrome.Link{Href: "/help/open-source", Label: "Open source"}, |
| } |
| switch q.Get("err") { |
| case "": |
| @@ -57,21 +52,17 @@ func (h *handlers) signin(w http.ResponseWriter, r *http.Request) { |
| failed := form.Error != "" || form.AddressError != "" |
| switch { |
| case sent && !failed: |
| - render(w, "signin", h.page(r, "Sign in", signinData{Sent: &chrome.SignInNotice{ |
| - Icon: form.Icon, |
| - App: form.App, |
| - Sky: form.Sky, |
| - Heading: "Check your email", |
| - Body: "The link works once, and expires shortly.", |
| - Link: &chrome.Link{Href: r.URL.Path, Label: "Send another link"}, |
| - }})) |
| + notice := chrome.SignInNotice{Icon: form.Icon, App: form.App, Sky: form.Sky}.Sent(r.URL.Path) |
| + render(w, "signin", h.page(r, notice.Page(), signinData{Sent: ¬ice})) |
| case sent: |
| // Somebody who asks for a second link after a failed one should |
| - // see both facts, not whichever the page happened to check first. |
| - form.Lede = "Check your email. The link works once, and expires shortly." |
| + // see both facts, not whichever the page happened to check first: |
| + // the form with its error, and the door's own sent sentence as |
| + // the lede. The only lede this page ever draws. |
| + form.Lede = chrome.English.SentBody |
| fallthrough |
| default: |
| - render(w, "signin", h.page(r, "Sign in", signinData{Form: form})) |
| + render(w, "signin", h.page(r, form.Page(), signinData{Form: form})) |
| } |
| } |
| @@ -83,27 +74,25 @@ type signinData struct { |
| SignedOut *chrome.SignInNotice |
| } |
| -// signedOutDoor is where Home sign-out lands, drawn as the door: Home's |
| -// own heading, sentence and "Sign in with Home" link, on Calendar's card. |
| -func signedOutDoor(r *http.Request, heading, body string, link chrome.Link) signinData { |
| - return signinData{SignedOut: &chrome.SignInNotice{ |
| - Icon: assets.Path("static/brand/icon.svg"), |
| - App: "Calendar", |
| - Sky: sky.For(r.Host), |
| - Heading: heading, |
| - Body: body, |
| - Link: &link, |
| - }} |
| +// signedOutDoor is where Home sign-out lands, drawn as the door: the |
| +// door's own "You’re signed out" card, whose Sign in goes to signin |
| +// (connect's Action). Home is never named to people. |
| +func signedOutDoor(r *http.Request, signin string) chrome.SignInNotice { |
| + return chrome.SignInNotice{ |
| + Icon: assets.Path("static/brand/icon.svg"), |
| + App: "Calendar", |
| + Sky: sky.For(r.Host), |
| + }.SignedOut(signin) |
| } |
| // signedOut serves GET /signed-out without Home: the same estilo door Home |
| -// mode's sign-out lands on, in Calendar's own words, so signing out is a |
| +// mode's sign-out lands on, in the door's words, so signing out is a |
| // page that says so rather than the sign-in form again (or, before, a |
| // 404 for anyone who reached the address). |
| func (h *handlers) signedOut(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Cache-Control", "no-store") |
| - render(w, "signin", h.page(r, "Signed out", signedOutDoor(r, "Signed out", |
| - "You’re signed out of Calendar on this browser.", chrome.Link{Href: "/signin", Label: "Sign in"}))) |
| + notice := signedOutDoor(r, "/signin") |
| + render(w, "signin", h.page(r, notice.Page(), signinData{SignedOut: ¬ice})) |
| } |
| // signOutToSignedOut is rastrillo's standalone Sign out with one change: |
diff --git a/internal/calendartest/signed_out_test.go b/internal/calendartest/signed_out_test.go| index ae50df1..6e95ce0 100644 |
| --- a/internal/calendartest/signed_out_test.go |
| +++ b/internal/calendartest/signed_out_test.go |
| @@ -30,7 +30,9 @@ func TestStandaloneSignOutLandsOnTheSignedOutPage(t *testing.T) { |
| for _, want := range []string{ |
| "<title>Signed out · Calendar</title>", |
| "estilo-door", |
| - "<h1>Signed out</h1>", |
| + // The door's words (estilo chrome.English), not the app's. |
| + "<h1>You’re signed out</h1>", |
| + "You’ve signed out of Calendar on this device.", |
| `<a rst-btn="primary" href="/signin">Sign in</a>`, |
| } { |
| if !strings.Contains(body, want) { |
diff --git a/internal/calendartest/signin_door_test.go b/internal/calendartest/signin_door_test.go| index 4186933..93e984d 100644 |
| --- a/internal/calendartest/signin_door_test.go |
| +++ b/internal/calendartest/signin_door_test.go |
| @@ -26,15 +26,24 @@ func TestSignInPageIsTheDoor(t *testing.T) { |
| "<span>Calendar</span>", |
| `<form rst-form estilo-signin-form method="post" action="/signin"`, |
| `name="address"`, |
| + "<title>Sign in · Calendar</title>", |
| // Paul, 2026-10-01: the door's button says "Sign in". |
| `<button rst-btn="primary" type="submit">Sign in</button>`, |
| - "We email you a link. There is no password to forget.", |
| + "We’ll email you a link that signs you in. It works once and expires in 15 minutes.", |
| } { |
| if !strings.Contains(body, want) { |
| t.Errorf("sign-in page is missing %q:\n%s", want, body) |
| } |
| } |
| + // The door owns its words (Paul, 2026-10-01): the submit is "Sign in", |
| + // and Calendar's own lede and button copy are gone. |
| + for _, gone := range []string{"Email me a link", "There is no password", "A calendar for one team"} { |
| + if strings.Contains(body, gone) { |
| + t.Errorf("sign-in page still says %q", gone) |
| + } |
| + } |
| + |
| // The sky: an inline placeholder, and served layers reachable signed |
| // out through estilo's handler, not the app's /static/ one. |
| if !regexp.MustCompile(`<img estilo-sky-layer="placeholder"[^>]*src="data:image/png;base64,`).MatchString(body) { |
| @@ -75,11 +84,12 @@ func TestSignInPageIsTheDoor(t *testing.T) { |
| func TestSignInDoorOutcomes(t *testing.T) { |
| h := newApp(t).Handler() |
| for query, want := range map[string][]string{ |
| - "sent=1": {"estilo-door", "Check your email", "The link works once, and expires shortly.", `href="/signin"`, "Send another link"}, |
| - "err=rate": {"estilo-signin-form", `role="alert"`, "Too many attempts. Wait a few minutes, then try again."}, |
| - "err=expired": {"estilo-signin-form", "That link has expired. Ask for a new one."}, |
| - "err=nope": {"estilo-signin-form", "That link did not work. Ask for a new one."}, |
| - "err=address": {`rst-field-error id="estilo-signin-address-error" role="alert">That does not look like an email address.`}, |
| + "sent=1": {"estilo-door", "<title>Check your email · Calendar</title>", "Check your email", "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"}, |
| + "err=rate": {"estilo-signin-form", `role="alert"`, "Too many attempts. Wait a few minutes, then try again."}, |
| + "err=expired": {"estilo-signin-form", "That link has expired. Ask for a new one."}, |
| + "err=nope": {"estilo-signin-form", "That link did not work. Ask for a new one."}, |
| + "err=expired&sent=1": {"estilo-signin-form", "<title>Sign in · Calendar</title>", "That link has expired. Ask for a new one.", "If that address can sign in here, we’ve sent it a link."}, |
| + "err=address": {`rst-field-error id="estilo-signin-address-error" role="alert">That does not look like an email address.`}, |
| } { |
| body := get(t, h, "/signin?"+query).Body.String() |
| for _, w := range want { |
| @@ -91,4 +101,12 @@ func TestSignInDoorOutcomes(t *testing.T) { |
| if body := get(t, h, "/signin?sent=1").Body.String(); strings.Contains(body, "estilo-signin-form") { |
| t.Error("/signin?sent=1 still draws the form; the check-your-email card replaces it") |
| } |
| + for _, query := range []string{"sent=1", "err=expired&sent=1"} { |
| + body := get(t, h, "/signin?"+query).Body.String() |
| + for _, gone := range []string{"expires shortly", "Send another link"} { |
| + if strings.Contains(body, gone) { |
| + t.Errorf("/signin?%s still says %q", query, gone) |
| + } |
| + } |
| + } |
| } |