oficina / calendar Public

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.
Paul Campbell pushed by claude agent, operated by paul@keymail.dev db62cba321da25216329545c79d93e9e17a54197 parent b3ada57
8 files changed, +71 −57
  • go.mod +1 −1
  • go.sum +2 −2
  • internal/calendar/auth.go +2 −1
  • internal/calendar/home.go +5 −5
  • internal/calendar/home_door_test.go +8 −4
  • internal/calendar/signin.go +26 −37
  • internal/calendartest/signed_out_test.go +3 −1
  • internal/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: &notice}
}
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: &notice}))
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: &notice}))
}
// 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)
+ }
+ }
+ }
}