Merge branch 'signin-door' into main
Merge signin-door: sign-in is the Oficina door
15 files changed,
+607
−71
go.mod+1 −1go.sum+4 −0internal/calendar/handlers.go+0 −40internal/calendar/home.go+7 −1internal/calendar/home_door_test.go+31 −0internal/calendar/render.go+8 −0internal/calendar/routes.go+5 −0internal/calendar/signin.go+97 −0internal/calendar/static/chrome.css+246 −0internal/calendar/static/sky.css+76 −0internal/calendar/templates/layout.html+4 −0internal/calendar/templates/signin.html+7 −28internal/calendartest/signin_door_test.go+93 −0internal/calendartest/vendored_test.go+22 −0scripts/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) |