oficina / calendar Public

Sign-in pages are the Oficina door

Paul wants every app's sign-in to be a version of the Oficina sign-in
screen: one card on the suite's sky, the app's own mark and name at its
head, in the app's colour. estilo's chrome package (cc2c1e5) draws that
door, so Calendar now renders it instead of its own page:

- /signin (and /signin/local with Home on) is estilo-signin; ?sent=1 is
  estilo-signin-sent, whose link returns to the same path for another
  link; Home's /signed-out is estilo-signed-out with Home's own words.
- Every outcome keeps its sentence. A bad address is now the field's
  own error; rate, expired and invalid links are the page's. The form
  still posts "address" to /signin, so gateBegin, the allowlist, the
  anti-oracle redirect, rate limits and CSRF are untouched.
- chrome.css and sky.css are written into static/ by writeestilo and
  pinned by TestDoorFilesAreEstilos, like theme.css and busy.css.
- The sky's layers are served by sky.Handler at sky.Prefix, mounted
  beside the font handler: GET only, sessionless, more specific than
  the app's /static/ handler. The inline placeholder needs img-src
  data:, which both rastrillo's default CSP and Home's form policy allow.

The signin handler moves out of handlers.go into signin.go.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev ae1527368997f77cdd700b31846a0e7adf424bda parent c533bb4
15 files changed, +599 −71
  • go.mod +1 −1
  • go.sum +2 −0
  • internal/calendar/handlers.go +0 −40
  • internal/calendar/home.go +7 −1
  • internal/calendar/home_door_test.go +31 −0
  • internal/calendar/render.go +8 −0
  • internal/calendar/routes.go +5 −0
  • internal/calendar/signin.go +97 −0
  • internal/calendar/static/chrome.css +240 −0
  • internal/calendar/static/sky.css +76 −0
  • internal/calendar/templates/layout.html +4 −0
  • internal/calendar/templates/signin.html +7 −28
  • internal/calendartest/signin_door_test.go +93 −0
  • internal/calendartest/vendored_test.go +22 −0
  • scripts/writeestilo/main.go +6 −1

CI passed — run details

CI log
=== vet ===
go vet ./...
go: downloading amadan.net/oficina/estilo.git v0.0.0-20260929183653-cc2c1e56fb4b
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/calendar/cmd/calendar	[no test files]
ok  	amadan.net/oficina/calendar/internal/calendar	4.583s
?   	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.414s
ok  	amadan.net/oficina/calendar/internal/guardstest	0.051s
?   	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 3708041..7c49a8e 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-20260929165053-846b39772f3c
+require amadan.net/oficina/estilo.git v0.0.0-20260929183653-cc2c1e56fb4b
require (
github.com/BurntSushi/toml v1.6.0 // indirect
diff --git a/go.sum b/go.sum
index 3d04629..8353236 100644
--- a/go.sum
+++ b/go.sum
@@ -8,6 +8,8 @@ amadan.net/oficina/estilo.git v0.0.0-20260929095838-0994675f496e h1:LTSmIWUS8RjP
amadan.net/oficina/estilo.git v0.0.0-20260929095838-0994675f496e/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
amadan.net/oficina/estilo.git v0.0.0-20260929165053-846b39772f3c h1:YYocIz3r3b4oIA0K/58JkSsmyTOCzMT/2GaW/MFbbPo=
amadan.net/oficina/estilo.git v0.0.0-20260929165053-846b39772f3c/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
+amadan.net/oficina/estilo.git v0.0.0-20260929183653-cc2c1e56fb4b h1:UOxYBKsOSRHcHHYNKt0i6cB1Z4yT2soywOTWURYvBWo=
+amadan.net/oficina/estilo.git v0.0.0-20260929183653-cc2c1e56fb4b/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/handlers.go b/internal/calendar/handlers.go
index 8ff2dd6..6d56c2e 100644
--- a/internal/calendar/handlers.go
+++ b/internal/calendar/handlers.go
@@ -60,46 +60,6 @@ func safeReturn(r *http.Request, fallback string) string {
return to
}
-// signin serves GET /signin — the form, and the landing spot for every
-// outcome the auth plugin's own handlers redirect back to. The plugin
-// never renders a page itself: it reports outcomes by redirecting here
-// with a query this handler reads.
-//
-// Every outcome on this page means the same operational thing — "sign
-// in did not complete, ask for a new link" — so the copy stays specific
-// where it can be and shares one honest sentence where it cannot. The
-// plugin deliberately refuses to distinguish unknown, used and expired
-// tokens from one another, so there is nothing finer this page could
-// draw.
-func (h *handlers) signin(w http.ResponseWriter, r *http.Request) {
- q := r.URL.Query()
- var d signinData
- if q.Get("sent") == "1" {
- d.Sent = "Check your email. The link works once, and expires shortly."
- }
- switch q.Get("err") {
- case "":
- case "rate":
- d.Error = "Too many attempts. Wait a few minutes, then try again."
- case "address":
- d.Error = "That does not look like an email address."
- case "expired":
- d.Error = "That link has expired. Ask for a new one."
- default:
- d.Error = "That link did not work. Ask for a new one."
- }
- render(w, "signin", h.page(r, "Sign in", d))
-}
-
-// signinData is the sign-in page's own view data. Sent and Error are
-// mutually exclusive in practice but not by construction: somebody who
-// asks for a second link after a failed one should see both facts, not
-// whichever the template happened to check first.
-type signinData struct {
- Sent string
- Error string
-}
-
// index serves GET / — the calendar itself.
func (h *handlers) index(w http.ResponseWriter, r *http.Request) {
render(w, "index", h.page(r, "Calendar", nil))
diff --git a/internal/calendar/home.go b/internal/calendar/home.go
index 898b44a..70e4c90 100644
--- a/internal/calendar/home.go
+++ b/internal/calendar/home.go
@@ -7,6 +7,7 @@ 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"
@@ -58,8 +59,13 @@ 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)
+ if state == "signedout" {
+ tree, view = pages["signin"], signedOutDoor(r, data.Title, data.Body, chrome.Link{Href: data.Action, Label: data.ActionLabel})
+ }
var buf bytes.Buffer
- if err := pages["home"].ExecuteTemplate(&buf, "layout", a.h.page(r, data.Title, data)); err != nil {
+ if err := tree.ExecuteTemplate(&buf, "layout", a.h.page(r, data.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
new file mode 100644
index 0000000..f4b48e5
--- /dev/null
+++ b/internal/calendar/home_door_test.go
@@ -0,0 +1,31 @@
+package calendar
+
+import (
+ "strings"
+ "testing"
+)
+
+// 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.
+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`},
+ } {
+ w := f.do("GET", path, "")
+ if w.Code != 200 {
+ t.Fatalf("GET %s: status %d", path, w.Code)
+ }
+ for _, s := range want {
+ if !strings.Contains(w.Body.String(), s) {
+ t.Errorf("GET %s is missing %q:\n%s", path, s, w.Body.String())
+ }
+ }
+ }
+ 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/render.go b/internal/calendar/render.go
index 13482fe..66b3418 100644
--- a/internal/calendar/render.go
+++ b/internal/calendar/render.go
@@ -1,7 +1,9 @@
package calendar
import (
+ "amadan.net/oficina/estilo.git/chrome"
"amadan.net/oficina/estilo.git/credits"
+ "amadan.net/oficina/estilo.git/menu"
"amadan.net/oficina/home/connect"
"bytes"
"embed"
@@ -68,6 +70,12 @@ func init() {
pages[name] = template.Must(pages[name].ParseFS(connect.Templates(), "templates/home_link.html"))
pages[name] = template.Must(pages[name].ParseFS(credits.Templates(), "templates/*.html"))
}
+ // The sign-in pages are estilo's door. chrome's templates use ui's
+ // field and callout partials, already in the tree, and name menu's
+ // templates from the bar, so menu is parsed alongside.
+ pages["signin"] = template.Must(template.Must(pages["signin"].
+ ParseFS(menu.Templates(), "templates/*.html")).
+ ParseFS(chrome.Templates(), "templates/*.html"))
}
// fragments is the partials on their own, with no layout around them.
diff --git a/internal/calendar/routes.go b/internal/calendar/routes.go
index 763e200..23ab309 100644
--- a/internal/calendar/routes.go
+++ b/internal/calendar/routes.go
@@ -1,6 +1,7 @@
package calendar
import (
+ "amadan.net/oficina/estilo.git/sky"
"amadan.net/oficina/estilo.git/theme"
"amadan.net/oficina/home/connect"
"amadan.net/oficina/home/handoff"
@@ -173,6 +174,10 @@ func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin
mux.Handle("GET /static/", assets.Handler())
// Onest, Calma's typeface, served by estilo under its versioned prefix.
mux.Handle("GET "+theme.FontPrefix, theme.FontHandler())
+ // The sign-in door's sky: a closed set of drawings, sessionless and
+ // reachable signed out. More specific than "GET /static/", so the
+ // app's own asset handler never sees these paths.
+ mux.Handle("GET "+sky.Prefix, sky.Handler())
mux.HandleFunc("GET /static/credits.css", serveCreditsCSS)
a.mountTick(mux, h)
mux.Handle("/", a.scheduled.middleware(r))
diff --git a/internal/calendar/signin.go b/internal/calendar/signin.go
new file mode 100644
index 0000000..5645d76
--- /dev/null
+++ b/internal/calendar/signin.go
@@ -0,0 +1,97 @@
+package calendar
+
+import (
+ "net/http"
+
+ "amadan.net/oficina/estilo.git/chrome"
+ "amadan.net/oficina/estilo.git/sky"
+)
+
+// 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.
+
+// signin serves GET /signin — the form, and the landing spot for every
+// outcome the auth plugin's own handlers redirect back to. The plugin
+// never renders a page itself: it reports outcomes by redirecting here
+// with a query this handler reads.
+//
+// Every outcome on this page means the same operational thing — "sign
+// in did not complete, ask for a new link" — so the copy stays specific
+// where it can be and shares one honest sentence where it cannot. The
+// plugin deliberately refuses to distinguish unknown, used and expired
+// tokens from one another, so there is nothing finer this page could
+// draw.
+//
+// ?sent=1 is the check-your-email card, whose link comes back to this
+// same path — /signin, or /signin/local when Home sign-in is on — so a
+// second link is one click away, as it was when the form stayed below
+// the notice.
+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"},
+ }
+ switch q.Get("err") {
+ case "":
+ case "rate":
+ form.Error = "Too many attempts. Wait a few minutes, then try again."
+ case "address":
+ form.AddressError = "That does not look like an email address."
+ case "expired":
+ form.Error = "That link has expired. Ask for a new one."
+ default:
+ form.Error = "That link did not work. Ask for a new one."
+ }
+ sent := q.Get("sent") == "1"
+ 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"},
+ }}))
+ 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."
+ fallthrough
+ default:
+ render(w, "signin", h.page(r, "Sign in", signinData{Form: form}))
+ }
+}
+
+// signinData is the sign-in page's own view data: exactly one of the
+// three door cards.
+type signinData struct {
+ Form *chrome.SignIn
+ Sent *chrome.SignInNotice
+ 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,
+ }}
+}
diff --git a/internal/calendar/static/chrome.css b/internal/calendar/static/chrome.css
new file mode 100644
index 0000000..63d7b3f
--- /dev/null
+++ b/internal/calendar/static/chrome.css
@@ -0,0 +1,240 @@
+/* chrome/chrome.css: the suite's top bar, account menu, app switcher and
+ sign-in pages. Link it after menu.css (its panels are [estilo-menu]).
+ Two custom properties are PUBLIC contract, read by apps and never
+ copied as numbers: the bar's block size and its stacking level. */
+:root {
+ --estilo-bar-block-size: 3.25rem; /* 52px. PUBLIC */
+ --estilo-z-bar: 45; /* PUBLIC: the bar's stacking level, and the ceiling for in-page layers */
+ --estilo-trigger-size: 2rem; /* 32px targets, above the 24px floor */
+}
+[estilo-bar] {
+ position: relative;
+ z-index: var(--estilo-z-bar);
+ box-sizing: border-box;
+ display: flex;
+ flex-wrap: nowrap;
+ align-items: center;
+ gap: var(--rst-sp-3);
+ block-size: var(--estilo-bar-block-size); /* fixed, not min: nothing may grow it */
+ padding-inline: var(--rst-sp-4);
+ background: var(--rst-surface);
+ color: var(--rst-text);
+ border-block-end: 1px solid var(--rst-line);
+}
+[estilo-bar~="sticky"] { position: sticky; inset-block-start: 0; }
+[estilo-bar-brand] {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex: none;
+ color: var(--rst-text);
+ font-weight: 650;
+ text-decoration: none;
+ white-space: nowrap;
+}
+[estilo-bar-brand] img { flex: none; inline-size: 1.75rem; block-size: 1.75rem; border-radius: 21%; }
+[estilo-bar-context] {
+ flex: 0 1 auto;
+ min-inline-size: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ padding-inline-start: var(--rst-sp-3);
+ border-inline-start: 1px solid var(--rst-line);
+ color: var(--rst-text);
+ font-weight: 550;
+}
+[estilo-bar-nav] { display: flex; align-self: stretch; gap: var(--rst-sp-4); }
+[estilo-bar-nav] a {
+ display: flex;
+ align-items: center;
+ border-block: 2px solid transparent; /* square current marker, in-flow */
+ color: var(--rst-text-muted);
+ font-size: var(--rst-fs-sm);
+ text-decoration: none;
+ white-space: nowrap;
+}
+[estilo-bar-nav] a:hover { color: var(--rst-text); }
+[estilo-bar-nav] a[aria-current] { border-block-end-color: var(--rst-accent); color: var(--rst-text); font-weight: 550; }
+[estilo-bar-end] { display: flex; align-items: center; gap: var(--rst-sp-2); flex: none; margin-inline-start: auto; }
+[estilo-bar-actions] { display: flex; align-items: center; gap: var(--rst-sp-2); }
+[estilo-bar-actions] form { display: contents; } /* a POST action lays out as its button */
+[estilo-bar-trigger] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.3rem;
+ box-sizing: border-box;
+ min-inline-size: var(--estilo-trigger-size);
+ block-size: var(--estilo-trigger-size);
+ padding-inline: 0.4rem;
+ border-radius: 0; /* in-flow: square */
+ color: var(--rst-text-muted);
+ font-size: var(--rst-fs-sm);
+ cursor: pointer;
+ list-style: none;
+ user-select: none;
+}
+[estilo-bar-trigger]::-webkit-details-marker { display: none; }
+[estilo-bar-trigger]::marker { content: ""; }
+[estilo-bar-trigger] svg { flex: none; inline-size: 1.125rem; block-size: 1.125rem; }
+[estilo-bar-trigger]:hover,
+[open] > [estilo-bar-trigger] { color: var(--rst-text); background: var(--rst-surface-2); }
+[estilo-bar-trigger]:focus-visible { outline: 2px solid var(--rst-accent); outline-offset: -2px; }
+[estilo-bar-more] { display: none; }
+@media (max-width: 799.98px) { /* rastrillo's shell breakpoint */
+ [estilo-bar-nav], [estilo-bar-actions] { display: none; }
+ [estilo-bar-more] { display: block; }
+ [estilo-bar]:has([estilo-bar-context], [estilo-bar-title]) [estilo-bar-name] { display: none; } /* the icon stays; the title gets the room */
+}
+
+/* The shell. <div estilo-shell> wraps the skip link, the bar and <main>
+ on every page and has no styling of its own. estilo-shell="fill" is the
+ full-height layout for the one kind of page that must fill the viewport
+ (Sheets' grid): the shell is exactly the viewport, the bar keeps its
+ 52px, and <main> takes the rest, so the working area is the viewport
+ less the bar without any app re-deriving that sum. The document never
+ scrolls there, so the root drops its reserved scrollbar gutter, which
+ would otherwise leave a dead strip down the inline end. <main> must be
+ the shell's direct child. */
+[estilo-shell~="fill"] {
+ display: flex;
+ flex-direction: column;
+ block-size: 100dvh;
+}
+[estilo-shell~="fill"] > [estilo-bar] { flex: none; }
+[estilo-shell~="fill"] > main { flex: 1 1 auto; min-block-size: 0; }
+:root:has([estilo-shell~="fill"]) { scrollbar-gutter: auto; }
+
+/* The editable title slot: the open object's title, renamed in place. The
+ input is a bordered control, so it keeps the theme's radius; its block size is
+ fixed so an input in the bar can never grow the bar. */
+[estilo-bar-title] {
+ display: flex;
+ align-items: center;
+ gap: var(--rst-sp-2);
+ flex: 0 1 22rem;
+ min-inline-size: 0;
+ margin: 0;
+ padding-inline-start: var(--rst-sp-3);
+ border-inline-start: 1px solid var(--rst-line);
+}
+[estilo-bar-title-input] {
+ box-sizing: border-box;
+ block-size: 2rem;
+ inline-size: 100%;
+ min-inline-size: 6rem;
+ padding-inline: 0.4rem;
+ border: 1px solid transparent;
+ border-radius: var(--rst-radius-sm);
+ background: transparent;
+ color: var(--rst-text);
+ font: inherit;
+ font-weight: 550;
+ text-overflow: ellipsis;
+}
+[estilo-bar-title-input]:hover { border-color: var(--rst-line); }
+[estilo-bar-title-input]:focus { border-color: var(--rst-accent); outline: none; background: var(--rst-surface); }
+[estilo-bar-title-submit] { position: absolute; clip-path: inset(50%); inline-size: 1px; block-size: 1px; overflow: hidden; white-space: nowrap; }
+[estilo-bar-title]:focus-within [estilo-bar-title-submit] { position: static; clip-path: none; inline-size: auto; block-size: 2rem; overflow: visible; flex: none; }
+/* Below the shell breakpoint the row has no room for the button beside the
+ field: it would sit under Menu or the switcher, and a click at its centre
+ would open them instead (found in the first review, at 390px and 320px).
+ So the field takes whatever width is left, down to nothing but its own
+ padding, and on focus the button floats just under it, over the page,
+ where nothing in the bar competes for it. Enter renames at every width. */
+@media (max-width: 799.98px) {
+ [estilo-bar-title] { position: relative; }
+ [estilo-bar-title-input] { min-inline-size: 0; }
+ [estilo-bar-title]:focus-within [estilo-bar-title-submit] {
+ position: absolute;
+ inset-block-start: calc(100% + 0.5rem);
+ inset-inline-start: var(--rst-sp-3);
+ border: 1px solid var(--rst-line);
+ border-radius: var(--estilo-float-radius, 9px); /* a float */
+ background: var(--rst-surface);
+ box-shadow: var(--rst-shadow-pop);
+ }
+}
+
+/* The app switcher: a float, so its tiles may be rounded. */
+[estilo-switcher] { position: relative; }
+[estilo-switcher-panel] { position: absolute; inset-inline-end: 0; inset-block-start: calc(100% + 4px); }
+[estilo-switcher-grid] { display: grid; grid-template-columns: repeat(3, 5.5rem); gap: 0.25rem; }
+[estilo-switcher-app] {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.35rem;
+ padding: 0.6rem 0.25rem;
+ border-radius: var(--rst-radius-sm); /* inside a float: rounded is allowed */
+ color: var(--rst-text);
+ font-size: var(--rst-fs-xs);
+ text-decoration: none;
+}
+[estilo-switcher-app]:hover,
+[estilo-switcher-app]:focus-visible { background: var(--rst-accent-soft); }
+[estilo-switcher-app][aria-current] { background: var(--rst-accent-soft); font-weight: 600; }
+[estilo-switcher-app] img { border-radius: 21%; }
+
+/* The account menu. The account is always the last thing at the inline
+ end of the bar: the "same corner" rule. */
+[estilo-account] { position: relative; }
+[estilo-account-panel] { position: absolute; inset-inline-end: 0; inset-block-start: calc(100% + 4px); min-inline-size: 15rem; }
+[estilo-avatar] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ inline-size: 1.75rem;
+ block-size: 1.75rem;
+ border-radius: 50%; /* an identity mark, not a highlight */
+ background: var(--rst-accent-soft);
+ color: var(--rst-accent);
+ font-size: var(--rst-fs-xs);
+ font-weight: 650;
+}
+[estilo-menu-identity] { display: grid; gap: 0.1rem; padding: 0.5rem var(--estilo-item-padding-inline, 0.65rem); }
+[estilo-menu-identity-label] { color: var(--rst-text-muted); font-size: var(--rst-fs-xs); }
+[estilo-menu-identity-name] { font-weight: 600; overflow-wrap: anywhere; }
+[estilo-menu-identity-address] { color: var(--rst-text-muted); overflow-wrap: anywhere; }
+[estilo-menu-identity-name] + [estilo-menu-identity-address] { font-size: var(--rst-fs-xs); }
+[estilo-bar-signin] { flex: none; }
+
+/* Visually hidden, still announced. */
+[estilo-visually-hidden] { position: absolute; clip-path: inset(50%); inline-size: 1px; block-size: 1px; overflow: hidden; white-space: nowrap; }
+
+/* Sign-in, check-your-email and signed-out pages: the Oficina door. The
+ suite's sky behind (estilo sky.css places and animates it), one card on
+ it with the app's mark and name at its head — the same door Home draws
+ at id.oficina.works, in the app's own colour. The door spans the page
+ whatever the app's layout gives it, so the sky is never boxed in. */
+:root:has([estilo-door]) { overflow-x: clip; --rst-scrollbar-gutter: auto; scrollbar-gutter: auto; }
+[estilo-door] {
+ box-sizing: border-box;
+ margin-inline: calc(50% - 50vw);
+ padding: max(40px, 9vh) var(--rst-sp-4) var(--rst-sp-6);
+ min-block-size: 70vh;
+ --estilo-sky-top: -140px;
+ --estilo-sky-height: 560px;
+}
+[estilo-signin] {
+ box-sizing: border-box;
+ max-inline-size: 460px;
+ margin: 0 auto;
+ padding: 34px 40px 32px;
+ background: var(--rst-surface);
+ border-radius: 20px;
+ box-shadow: 0 0 0 1px light-dark(transparent, var(--rst-line)), var(--rst-shadow-lift);
+}
+[estilo-signin] > [estilo-brand] { display: flex; align-items: center; gap: 10px; margin: 0 0 22px; font-size: 1.2rem; font-weight: 650; letter-spacing: -0.01em; }
+[estilo-signin] > [estilo-brand] [estilo-brand-icon] { inline-size: 36px; block-size: 36px; }
+[estilo-signin] h1 { margin: 0; font-size: 1.75rem; font-weight: 620; letter-spacing: -0.02em; line-height: 1.15; }
+[estilo-signin-lede] { margin: 0.5rem 0 var(--rst-sp-4); color: var(--rst-text-muted); }
+[estilo-signin] > h1 + :not([estilo-signin-lede]) { margin-block-start: var(--rst-sp-4); }
+[estilo-signin-form] { max-inline-size: none; margin-block-start: var(--rst-sp-4); }
+[estilo-signin-body] { margin: var(--rst-sp-4) 0; }
+@media (max-width: 520px) {
+ [estilo-door] { padding-inline: 14px; padding-block-start: 28px; }
+ [estilo-signin] { padding: 26px 22px 24px; }
+}
+[estilo-signin-foot] { margin-block-start: var(--rst-sp-6); font-size: var(--rst-fs-xs); color: var(--rst-text-muted); }
diff --git a/internal/calendar/static/sky.css b/internal/calendar/static/sky.css
new file mode 100644
index 0000000..50067a9
--- /dev/null
+++ b/internal/calendar/static/sky.css
@@ -0,0 +1,76 @@
+/* estilo-sky — Calma's ambient nebula (sky.go). Quiet by default:
+ --estilo-sky-strength is one number for the whole effect.
+
+ Placement is the host's: [estilo-sky-host] is a positioned, clipped box,
+ and a stacking context of its own, so the sky (z-index -1) paints above
+ the host's background and beneath everything else in it, without
+ touching how the host's other children are positioned — a sticky bar
+ stays sticky. Move the sky with --estilo-sky-top and
+ --estilo-sky-height, and set --estilo-sky-strength (0.5, Quiet) on the
+ host or anywhere above it. */
+[estilo-sky-host] {
+ position: relative;
+ overflow: hidden;
+ isolation: isolate;
+}
+
+[estilo-sky] {
+ position: absolute;
+ inset-inline: 0;
+ top: var(--estilo-sky-top, -6%);
+ height: var(--estilo-sky-height, 80%);
+ pointer-events: none;
+ z-index: -1;
+}
+
+/* Each layer overhangs the box so a drift never shows an edge, and fades
+ in and out vertically so the sky has no top or bottom. */
+[estilo-sky] img {
+ position: absolute;
+ left: -8%;
+ top: -12%;
+ width: 116%;
+ height: 124%;
+ max-width: none;
+ opacity: calc(var(--estilo-sky-strength, 0.5) * var(--estilo-sky-gain, 1) * var(--estilo-sky-k, 1));
+ -webkit-mask-image: linear-gradient(#0000, #000 22%, #000 58%, #0000 92%);
+ mask-image: linear-gradient(#0000, #000 22%, #000 58%, #0000 92%);
+ user-select: none;
+}
+/* The sky's colour is the placeholder: the haze and clouds computed into a
+ few pixels and inlined, so it is there in the first frame (placeholder.go
+ has the measurements against the full SVG drawing: 3/255 light, 8/255
+ dark). Stretched to cover as an SVG's xMidYMid slice would, and softened
+ so no pixel grid shows. The haze and clouds SVGs it replaced were
+ drawing almost nothing on top of it, so they are no longer linked
+ (impeccable's detector, 2026-09-29). Over it, two SVG layers carry what
+ a few pixels cannot: the clouds' grain — faint light and dark specks that
+ average to nothing — and the filaments. No script. */
+[estilo-sky] img { object-fit: cover; }
+[estilo-sky-layer="placeholder"] { filter: blur(24px); }
+[estilo-sky-layer="grain"] { --estilo-sky-k: 1; }
+/* The weights haze and clouds had when they were linked, kept for a page
+ cached from then that still names them. */
+[estilo-sky-layer="haze"] { --estilo-sky-k: 0.175; }
+[estilo-sky-layer="clouds"] { --estilo-sky-k: 0.25; }
+[estilo-sky-layer="filaments"] { --estilo-sky-k: 1.3; }
+
+/* One placeholder per scheme; show the one that matches. An explicit
+ Appearance choice ([data-theme]) beats the OS, as it does for estilo's
+ theme. Both are inline, so neither costs a request. */
+[estilo-sky-tone="dark"] { display: none; --estilo-sky-gain: 1.55; }
+:root[data-theme="dark"] [estilo-sky-tone="light"] { display: none; }
+:root[data-theme="dark"] [estilo-sky-tone="dark"] { display: block; }
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) [estilo-sky-tone="light"] { display: none; }
+ :root:not([data-theme="light"]) [estilo-sky-tone="dark"] { display: block; }
+}
+
+/* Drifting: the sky breathes, the grain with it, and the filaments
+ slide. Slow enough to notice only if you look. */
+@media (prefers-reduced-motion: no-preference) {
+ [estilo-sky-layer="placeholder"], [estilo-sky-layer="grain"] { animation: estilo-sky-breathe-near 64s ease-in-out infinite alternate; }
+ [estilo-sky-layer="filaments"] { animation: estilo-sky-drift 80s ease-in-out infinite alternate; }
+}
+@keyframes estilo-sky-breathe-near { to { transform: translate(-3%, 1.5%) scale(1.06); } }
+@keyframes estilo-sky-drift { to { transform: translate(-4%, 1%); } }
diff --git a/internal/calendar/templates/layout.html b/internal/calendar/templates/layout.html
index 204ccda..9ee2e14 100644
--- a/internal/calendar/templates/layout.html
+++ b/internal/calendar/templates/layout.html
@@ -18,6 +18,10 @@
<link rel="stylesheet" href="{{asset "static/tokens.css"}}">
<link rel="stylesheet" href="{{asset "static/busy.css"}}">
<link rel="stylesheet" href="{{asset "static/theme.css"}}">
+{{/* estilo's page chrome (the sign-in door) and the suite's sky behind
+ it. Both only style [estilo-*] markup. */}}
+<link rel="stylesheet" href="{{asset "static/chrome.css"}}">
+<link rel="stylesheet" href="{{asset "static/sky.css"}}">
<script defer src="{{asset "static/rastrillo.js"}}"></script>
<script defer src="{{asset "static/busy.js"}}"></script>
{{iconAssets}}
diff --git a/internal/calendar/templates/signin.html b/internal/calendar/templates/signin.html
index e2bce4f..0eac71b 100644
--- a/internal/calendar/templates/signin.html
+++ b/internal/calendar/templates/signin.html
@@ -1,31 +1,10 @@
{{define "title"}}{{.Title}}{{end}}
+{{/* The Oficina door (estilo chrome). Exactly one card: the form, the
+ check-your-email notice, or where Home sign-out lands. The form posts
+ its address as "address", the name gateBegin and the auth plugin
+ read. */}}
{{define "content"}}
-<header rst-page-header>
- <div rst-page-header-titles>
- <h1>{{.Title}}</h1>
- <p rst-page-header-sub>A calendar for one team, on one instance.</p>
- </div>
-</header>
-
-{{with .Data.Error}}
-{{/* Alert, not a plain callout: this reports a live problem with the
- thing the person just did, which is what role="alert" is for. */}}
-{{template "callout" dict "Tone" "negative" "Body" . "Alert" true}}
-{{end}}
-
-{{with .Data.Sent}}
-{{template "callout" dict "Tone" "positive" "Body" .}}
-{{end}}
-
-<form rst-form method="post" action="/signin">
- {{template "field" dict
- "ID" "address"
- "Name" "address"
- "Label" "Email address"
- "Type" "email"
- "Autocomplete" "email"
- "Required" true
- "Hint" "We email you a link. There is no password to forget."}}
- {{template "form-foot" dict "Submit" "Email me a link"}}
-</form>
+{{- with .Data.Form}}{{template "estilo-signin" .}}{{end}}
+{{- with .Data.Sent}}{{template "estilo-signin-sent" .}}{{end}}
+{{- with .Data.SignedOut}}{{template "estilo-signed-out" .}}{{end}}
{{end}}
diff --git a/internal/calendartest/signin_door_test.go b/internal/calendartest/signin_door_test.go
new file mode 100644
index 0000000..b61861c
--- /dev/null
+++ b/internal/calendartest/signin_door_test.go
@@ -0,0 +1,93 @@
+package calendartest
+
+import (
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+// The sign-in pages are the Oficina door (estilo chrome, Paul
+// 2026-09-29): one card on the suite's sky, Calendar's mark and name at
+// its head. Only the drawing changed — the form still posts "address" to
+// /signin and gets the same answer it always did.
+func TestSignInPageIsTheDoor(t *testing.T) {
+ app := newApp(t)
+ h := app.Handler()
+
+ rec := get(t, h, "/signin")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET /signin: status %d, want 200", rec.Code)
+ }
+ body := rec.Body.String()
+ for _, want := range []string{
+ "estilo-door",
+ "<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.",
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("sign-in page is missing %q:\n%s", want, body)
+ }
+ }
+
+ // 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) {
+ t.Errorf("sign-in page has no inline estilo-sky-layer placeholder:\n%s", body)
+ }
+ layer := regexp.MustCompile(`<img estilo-sky-layer="[a-z]+"[^>]*src="(/static/estilo/sky/v1/[^"]+)"`).FindStringSubmatch(body)
+ if layer == nil {
+ t.Fatalf("sign-in page links no served estilo-sky-layer img:\n%s", body)
+ }
+ if got := get(t, h, layer[1]); got.Code != http.StatusOK || got.Header().Get("Content-Type") != "image/svg+xml" {
+ t.Errorf("GET %s signed out: %d %q, want 200 image/svg+xml", layer[1], got.Code, got.Header().Get("Content-Type"))
+ }
+
+ // The door's own styles are linked and served.
+ for _, name := range []string{"chrome", "sky"} {
+ href := regexp.MustCompile(`/static/` + name + `\.[0-9a-f]{16}\.css`).FindString(body)
+ if href == "" {
+ t.Errorf("sign-in page does not link static/%s.css", name)
+ continue
+ }
+ if got := get(t, h, href); got.Code != http.StatusOK {
+ t.Errorf("GET %s: status %d, want 200", href, got.Code)
+ }
+ }
+
+ // POST address= still begins sign-in exactly as before.
+ posted := post(t, h, "/signin", url.Values{"address": {"someone@example.com"}})
+ if posted.Code != http.StatusSeeOther || posted.Header().Get("Location") != "/signin?sent=1" {
+ t.Fatalf("POST /signin: %d to %q, want 303 to /signin?sent=1", posted.Code, posted.Header().Get("Location"))
+ }
+ if mail, err := app.LastMailForTest(); err != nil || !strings.Contains(mail, "/auth/verify?token=") {
+ t.Fatalf("POST /signin address= mailed no link (err %v)", err)
+ }
+}
+
+// Every outcome the auth plugin redirects back with keeps its sentence;
+// a bad address is the field's own error, the rest are the page's.
+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.`},
+ } {
+ body := get(t, h, "/signin?"+query).Body.String()
+ for _, w := range want {
+ if !strings.Contains(body, w) {
+ t.Errorf("/signin?%s is missing %q:\n%s", query, w, body)
+ }
+ }
+ }
+ 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")
+ }
+}
diff --git a/internal/calendartest/vendored_test.go b/internal/calendartest/vendored_test.go
index 100f17f..7c3bc22 100644
--- a/internal/calendartest/vendored_test.go
+++ b/internal/calendartest/vendored_test.go
@@ -2,6 +2,8 @@ package calendartest
import (
estilobusy "amadan.net/oficina/estilo.git/busy"
+ "amadan.net/oficina/estilo.git/chrome"
+ "amadan.net/oficina/estilo.git/sky"
"amadan.net/oficina/estilo.git/theme"
"bytes"
"os"
@@ -98,3 +100,23 @@ func TestBusyFilesAreEstilos(t *testing.T) {
}
}
}
+
+// The sign-in door's styles (Paul, 2026-09-29: every app's sign-in is a
+// version of the Oficina sign-in screen): estilo chrome's chrome.css,
+// which holds the [estilo-door] and [estilo-signin] rules, and the sky
+// behind it. Pinned to the estilo this app builds against. After an
+// estilo upgrade: go run ./scripts/writeestilo.
+func TestDoorFilesAreEstilos(t *testing.T) {
+ for name, want := range map[string][]byte{
+ "chrome.css": chrome.CSS(),
+ "sky.css": sky.CSS(),
+ } {
+ got, err := os.ReadFile(filepath.Join("..", "calendar", "static", name))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(got, want) {
+ t.Errorf("static/%s differs from estilo's; run go run ./scripts/writeestilo", name)
+ }
+ }
+}
diff --git a/scripts/writeestilo/main.go b/scripts/writeestilo/main.go
index 930cc4a..a12f7f5 100644
--- a/scripts/writeestilo/main.go
+++ b/scripts/writeestilo/main.go
@@ -1,7 +1,8 @@
// Command writeestilo re-writes the files internal/calendar/static takes from
// the estilo this module pins: theme.css (Calma, with Calendar's accent),
// and rastrillo's busy rule as estilo carries it — rastrillo.js without
-// the old rule, busy.js and busy.css. Run it from the repository root
+// the old rule, busy.js and busy.css — and the sign-in door's chrome.css
+// and sky.css. Run it from the repository root
// after upgrading estilo; the pin tests in internal/calendartest fail until you do.
package main
@@ -9,6 +10,8 @@ import (
"os"
"amadan.net/oficina/estilo.git/busy"
+ "amadan.net/oficina/estilo.git/chrome"
+ "amadan.net/oficina/estilo.git/sky"
"amadan.net/oficina/estilo.git/theme"
)
@@ -22,6 +25,8 @@ func main() {
"rastrillo.js": busy.ShimJS(),
"busy.js": busy.JS(),
"busy.css": busy.CSS(),
+ "chrome.css": chrome.CSS(),
+ "sky.css": sky.CSS(),
} {
if err := os.WriteFile("internal/calendar/static/"+name, body, 0o644); err != nil {
panic(err)