oficina / calendar Public

Door copy: take the sign-in words from estilo's door

The door's words now live in estilo (chrome.English, feat/door-copy), so
every app says the same thing on its sign-in, sent and signed-out cards.
Calendar drops its own copy: the "A calendar for one team" lede, the
help line, the "Email me a link" submit (Paul, 2026-10-01: it reads
"Sign in"), the "expires shortly" sent body and "Send another link".
Where Home sign-out lands, the card is the door's "You're signed out"
rather than connect's "Sign in with Home", since Home is never named to
people (Paul, 2026-09-23); connect's Action stays the href.

The sent-after-a-failure state (?sent=1 with an error) keeps the form
and its error, with the door's own sent sentence as the lede instead of
"expires shortly". Tab titles become "Sign in · Calendar", "Check your
email · Calendar" and "Signed out · Calendar" (the layout prints the
title as given). The email subject is chrome.EmailSubject("Calendar");
the body waits on rastrillo's Config.Body.

Pins estilo fbd14e7; re-pin to estilo main once feat/door-copy lands.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 01c34a32e4a533e61a62590885b8a1d0fb80528a parent 25974c6
7 files changed, +66 −54
  • 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 +23 −34
  • internal/calendartest/signin_door_test.go +25 −7

CI passed — run details

CI log
=== vet ===
go vet ./...
go: downloading amadan.net/oficina/estilo.git v0.0.0-20261001075355-fbd14e759027
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/calendar/cmd/calendar	[no test files]
ok  	amadan.net/oficina/calendar/internal/calendar	4.548s
?   	amadan.net/oficina/calendar/internal/calendar/icons	[no test files]
?   	amadan.net/oficina/calendar/internal/calendar/seal	[no test files]
ok  	amadan.net/oficina/calendar/internal/calendartest	2.228s
ok  	amadan.net/oficina/calendar/internal/guardstest	0.041s
?   	amadan.net/oficina/calendar/scripts/writeestilo	[no test files]
=== migration-check ===
go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
diff --git a/go.mod b/go.mod
index 5188792..1344ca2 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-20261001075355-fbd14e759027
require (
github.com/BurntSushi/toml v1.6.0 // indirect
diff --git a/go.sum b/go.sum
index 1b25fc6..b5f639e 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-20261001075355-fbd14e759027 h1:+NFDWW5ROkLaFIsT9flDuAjNeQJJxj0Safk32UcRyn0=
+amadan.net/oficina/estilo.git v0.0.0-20261001075355-fbd14e759027/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f h1:lkw3tjzHfZxe74pNfo+tGDz4mkZLg+D6R+FTkmsym6M=
amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f/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 70e4c90..70cb27c 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.Title(), 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 {
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 5645d76..fc115ff 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: "Email me a link",
- 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.Title(), 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.Title(), signinData{Form: form}))
}
}
@@ -83,15 +74,13 @@ 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)
}
diff --git a/internal/calendartest/signin_door_test.go b/internal/calendartest/signin_door_test.go
index b61861c..ccaac45 100644
--- a/internal/calendartest/signin_door_test.go
+++ b/internal/calendartest/signin_door_test.go
@@ -26,14 +26,23 @@ func TestSignInPageIsTheDoor(t *testing.T) {
"<span>Calendar</span>",
`<form rst-form estilo-signin-form method="post" action="/signin"`,
`name="address"`,
- "Email me a link",
- "We email you a link. There is no password to forget.",
+ "<title>Sign in · Calendar</title>",
+ `type="submit">Sign in</button>`,
+ "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) {
@@ -74,11 +83,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 {
@@ -90,4 +100,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)
+ }
+ }
+ }
}