oficina / calendar Public

Merge branch 'signin-door' into main

Merge signin-door: sign-in is the Oficina door
claude agent, operated by paul@keymail.dev 9b743e61809f20c1219629dbc5aec01e1821f427 parent c533bb4parent 3f1b725
15 files changed, +607 −71
  • go.mod +1 −1
  • go.sum +4 −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 +246 −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-20260929185827-41201439fd22
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/calendar/cmd/calendar	[no test files]
ok  	amadan.net/oficina/calendar/internal/calendar	4.520s
?   	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.124s
ok  	amadan.net/oficina/calendar/internal/guardstest	0.039s
?   	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..3dd7510 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-20260929185827-41201439fd22
require (
github.com/BurntSushi/toml v1.6.0 // indirect
diff --git a/go.sum b/go.sum
index 3d04629..9e62aca 100644
--- a/go.sum
+++ b/go.sum
@@ -8,6 +8,10 @@ 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/estilo.git v0.0.0-20260929185827-41201439fd22 h1:RR9qP85FU1cZdn788v1zSLgZbKMGBmSk6Q7Rezj5JYI=
+amadan.net/oficina/estilo.git v0.0.0-20260929185827-41201439fd22/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..1ff46e3
--- /dev/null
+++ b/internal/calendar/static/chrome.css
@@ -0,0 +1,246 @@
+/* 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: 680px;
+ /* The whole sky lies inside the door, fading in and out, so the door's
+ edges never cut it: under an app's bar there is no page top to hide
+ an edge behind, as there is on Home. A sky's layers overhang its box
+ by 12% above and below (sky.css), and fade in over their first 22%
+ and out from 58% to 92% — so a 560px sky placed 67px down starts
+ transparent at the door's top and has faded out by 640px. */
+ --estilo-sky-top: 67px;
+ --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)