Sign-in is the Oficina door: one card on the team's sky, Memoria's mark at its head
Paul's ruling is that every app's sign-in is a version of the Oficina sign-in screen. estilo's chrome package now draws that door (estilo-signin, estilo-signin-sent), so memoria renders it instead of its own bare form. - /signin (and /signin/local with Home) renders estilo-signin with App "Memoria", the team's sky (sky.For(r.Host)) and the brand icon. Same POST to /auth/begin, same field name (address), same hidden force=1 under Home. Errors go in the door's negative callout; ?reauth=1 stands where the lede does, since it is not an error. - ?sent=1 is the door's check-your-email notice, which replaces the form. It is byte-identical for an owner and a stranger, and the test now asserts that as well as the notice. - The Keymail continuation page keeps Home's meta refresh and words and wears the door's notice, so the moment between the door and Keymail is the same card rather than a bare page. - The layout footer, and with it the egress claim (lexical search; AI on or off), stays under the door; the footer gates still hold it. - chrome.css and sky.css are vendored by scripts/writeestilo and pinned byte-identical to estilo; the sky is served at a literal /static/estilo/sky/v1/ (declared in the route gate, and pinned to sky.Prefix like the font). CSP is rastrillo's default, which already allows img-src 'self' data: for the inline placeholder. - estilo pinned to cc2c1e5, the door commit. TestSigninPageIsTheDoor checks the page is the door, that every linked stylesheet and sky layer is served, and that a POST using the field name read off the rendered page still reaches sign-in.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
15 files changed,
+604
−26
go.mod+1 −1go.sum+2 −0internal/memoria/app.go+6 −0internal/memoria/auth.go+15 −1internal/memoria/handlers.go+56 −2internal/memoria/render.go+16 −1internal/memoria/static/chrome.css+240 −0internal/memoria/static/sky.css+76 −0internal/memoria/templates/signin.html+7 −12internal/memoria/templates/signin_oauth.html+7 −4internal/memoriatest/routes_test.go+7 −3internal/memoriatest/signin_door_test.go+109 −0internal/memoriatest/signinoutcome_test.go+21 −1internal/memoriatest/vendored_test.go+35 −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-20260929183653-cc2c1e56fb4b === fmt === === test === go test ./... ? amadan.net/oficina/memoria/cmd/memoria [no test files] ok amadan.net/oficina/memoria/internal/memoria 3.765s ok amadan.net/oficina/memoria/internal/memoria/chunk 0.007s ok amadan.net/oficina/memoria/internal/memoria/fact 0.710s ? amadan.net/oficina/memoria/internal/memoria/icons [no test files] ok amadan.net/oficina/memoria/internal/memoria/index 0.043s ok amadan.net/oficina/memoria/internal/memoria/notion 0.027s ok amadan.net/oficina/memoria/internal/memoria/provider 1.138s ok amadan.net/oficina/memoria/internal/memoria/query 0.007s ok amadan.net/oficina/memoria/internal/memoria/seal 0.003s ok amadan.net/oficina/memoria/internal/memoria/sibling 3.028s ok amadan.net/oficina/memoria/internal/memoriatest 1.929s ? amadan.net/oficina/memoria/scripts/writeestilo [no test files] ? amadan.net/oficina/memoria/tools/bench [no test files] ? amadan.net/oficina/memoria/tools/drive [no test files] ? amadan.net/oficina/memoria/tools/seed [no test files] ? amadan.net/oficina/memoria/tools/vendorassets [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 2b55040..72c2b15 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -5,7 +5,7 @@ go 1.26 |
| toolchain go1.26.5 |
| require ( |
| - amadan.net/oficina/estilo.git v0.0.0-20260929165053-846b39772f3c |
| + amadan.net/oficina/estilo.git v0.0.0-20260929183653-cc2c1e56fb4b |
| amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| amadan.net/oficina/suite v0.0.0-20260905215529-60b01a8a0d6d |
diff --git a/go.sum b/go.sum| index f4ea6fa..1a8e59f 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,5 +1,7 @@ |
| 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/memoria/app.go b/internal/memoria/app.go| index 50b44f8..b2e3501 100644 |
| --- a/internal/memoria/app.go |
| +++ b/internal/memoria/app.go |
| @@ -1,6 +1,7 @@ |
| package memoria |
| import ( |
| + "amadan.net/oficina/estilo.git/sky" |
| "amadan.net/oficina/estilo.git/theme" |
| "context" |
| "log/slog" |
| @@ -167,6 +168,11 @@ func App(d *db.DB, origin string, sealer *seal.Sealer, logger *slog.Logger) (*ht |
| // prefix. A literal, so the route gate can read it; |
| // TestFontRouteIsEstilosPrefix keeps it equal to theme.FontPrefix. |
| mux.Handle("GET /static/estilo/fonts/v1/", theme.FontHandler()) |
| + // The sign-in door's sky (estilo sky), under its versioned prefix: |
| + // public artwork, served before any session gate like the font. A |
| + // literal for the same reason; TestSkyRouteIsEstilosPrefix keeps it |
| + // equal to sky.Prefix. |
| + mux.Handle("GET /static/estilo/sky/v1/", sky.Handler()) |
| mux.Handle("/", r) |
| return mux, a, nil |
| } |
diff --git a/internal/memoria/auth.go b/internal/memoria/auth.go| index d5de3e1..6b4b162 100644 |
| --- a/internal/memoria/auth.go |
| +++ b/internal/memoria/auth.go |
| @@ -7,6 +7,8 @@ import ( |
| "net/http" |
| "strings" |
| + "amadan.net/oficina/estilo.git/chrome" |
| + "amadan.net/oficina/estilo.git/sky" |
| "amadan.net/oficina/home/connect" |
| "github.com/carlosframework/rastrillo/auth" |
| "github.com/carlosframework/rastrillo/mail" |
| @@ -243,14 +245,26 @@ func (a *app) signinBegin(origin string, begin http.HandlerFunc) http.HandlerFun |
| // renderOAuth renders the continuation page inside memoria's layout, footer |
| // and AI state included — it is a page like any other. |
| +// |
| +// It wears the sign-in door, so the moment between the door and Keymail |
| +// is the same card on the same sky rather than a bare page. Home's |
| +// words and destination (connect.OAuthPage) and Home's meta refresh |
| +// (home-oauth-head) are unchanged; only the body is the door's notice. |
| func (a *app) renderOAuth(w http.ResponseWriter, r *http.Request, destination string) { |
| + data := connect.OAuthPage(destination) |
| render(w, "signin_oauth", oauthPage{ |
| page: page{Chunks: a.chunkCount(), ModelOn: a.modelOn()}, |
| - Data: connect.OAuthPage(destination), |
| + Data: data, |
| + Door: chrome.SignInNotice{ |
| + Icon: assets.Path("static/brand/icon.svg"), App: "Memoria", Sky: sky.For(r.Host), |
| + Heading: data.Title, Body: "Taking you to Keymail to finish signing in.", |
| + Link: &chrome.Link{Href: data.URL, Label: data.ActionLabel}, |
| + }, |
| }) |
| } |
| type oauthPage struct { |
| page |
| Data connect.OAuthData |
| + Door chrome.SignInNotice |
| } |
diff --git a/internal/memoria/handlers.go b/internal/memoria/handlers.go| index bc9e64f..bd34e24 100644 |
| --- a/internal/memoria/handlers.go |
| +++ b/internal/memoria/handlers.go |
| @@ -13,6 +13,8 @@ import ( |
| "strconv" |
| "strings" |
| + "amadan.net/oficina/estilo.git/chrome" |
| + "amadan.net/oficina/estilo.git/sky" |
| "github.com/carlosframework/rastrillo/auth" |
| "amadan.net/oficina/suite" |
| @@ -1121,10 +1123,62 @@ func (a *app) deleteDocument(w http.ResponseWriter, r *http.Request) { |
| http.Redirect(w, r, "/", http.StatusSeeOther) |
| } |
| +// signinPage is the sign-in page: the layout's page (footer, egress claim |
| +// and all) around the suite's door. Exactly one of Door and Sent is set — |
| +// the form, or the check-your-email notice that replaces it. |
| +type signinPage struct { |
| + page |
| + Door *chrome.SignIn |
| + Sent *chrome.SignInNotice |
| +} |
| + |
| +// signinLede is what the door says under its heading on a plain visit. |
| +const signinLede = "Memoria holds your team's work in one place. Sign in with your email and we'll send you a link." |
| + |
| func (a *app) localSignin(w http.ResponseWriter, r *http.Request) { |
| - p := page{Chunks: a.chunkCount(), ModelOn: a.modelOn()} |
| + p := signinPage{page: page{Chunks: a.chunkCount(), ModelOn: a.modelOn()}} |
| p.ForceEmail = a.home != nil && r.URL.Query().Get("force") == "1" |
| - p.Flash, p.FlashKind = signinOutcome(r.URL.Query()) |
| + msg, kind := signinOutcome(r.URL.Query()) |
| + icon, view := assets.Path("static/brand/icon.svg"), sky.For(r.Host) |
| + |
| + // A sent link is the door's own check-your-email state, not a flash |
| + // over the form. The same page for an owner and a stranger: beginGuard |
| + // answers both with ?sent=1, and nothing here reads anything else. |
| + if kind == "good" { |
| + back := r.URL.Path |
| + if p.ForceEmail { |
| + back += "?force=1" |
| + } |
| + // signinOutcome's sentence, in the notice's two parts. |
| + p.Sent = &chrome.SignInNotice{ |
| + Icon: icon, App: "Memoria", Sky: view, |
| + Heading: "Check your email", Body: "We've sent you a link to sign in.", |
| + Link: &chrome.Link{Href: back, Label: "Back to sign in"}, |
| + } |
| + render(w, "signin", p) |
| + return |
| + } |
| + |
| + door := &chrome.SignIn{ |
| + Icon: icon, App: "Memoria", Sky: view, |
| + Heading: "Sign in", Lede: signinLede, |
| + Action: "/auth/begin", |
| + AddressLabel: "Email address", |
| + Submit: "Email me a link", |
| + } |
| + if p.ForceEmail { |
| + door.Hidden = []chrome.Hidden{{Name: "force", Value: "1"}} |
| + } |
| + switch kind { |
| + case "": |
| + case "warn": |
| + // ?reauth=1. Not an error, so not the door's negative callout: |
| + // it stands where the lede does, and says why the form is back. |
| + door.Lede = msg |
| + default: |
| + door.Error = msg |
| + } |
| + p.Door = door |
| render(w, "signin", p) |
| } |
diff --git a/internal/memoria/render.go b/internal/memoria/render.go| index d9b89ad..93e33c0 100644 |
| --- a/internal/memoria/render.go |
| +++ b/internal/memoria/render.go |
| @@ -7,9 +7,11 @@ import ( |
| "net/http" |
| "strings" |
| + "amadan.net/oficina/estilo.git/chrome" |
| "amadan.net/oficina/estilo.git/credits" |
| "amadan.net/oficina/home/connect" |
| "github.com/carlosframework/rastrillo" |
| + "github.com/carlosframework/rastrillo/ui" |
| "amadan.net/oficina/memoria/internal/memoria/icons" |
| ) |
| @@ -52,11 +54,24 @@ func funcs() template.FuncMap { |
| } |
| func init() { |
| - for _, name := range []string{"index", "add", "document", "signin", "signin_oauth", "connectors", "notion", "home", "credits"} { |
| + for _, name := range []string{"index", "add", "document", "connectors", "notion", "home", "credits"} { |
| pages[name] = template.Must(template.New("layout"). |
| Funcs(funcs()). |
| ParseFS(appFS, "templates/layout.html", "templates/results.html", "templates/"+name+".html")) |
| } |
| + // The sign-in pages are the suite's door (estilo chrome: estilo-signin, |
| + // estilo-signin-sent), which is built from rastrillo's field and |
| + // callout partials — so those two trees also carry ui's templates and |
| + // ui's funcs (dict). ui.Funcs goes first and funcs() over it, so icon |
| + // and iconAssets stay this app's own set rather than reverting to |
| + // rastrillo's built-in one (ui.FuncsWith's documented trap). |
| + for _, name := range []string{"signin", "signin_oauth"} { |
| + pages[name] = template.Must(template.Must(template.Must(template.New("layout"). |
| + Funcs(ui.Funcs()).Funcs(funcs()). |
| + ParseFS(appFS, "templates/layout.html", "templates/results.html", "templates/"+name+".html")). |
| + ParseFS(ui.Templates(), "*.html")). |
| + ParseFS(chrome.Templates(), "templates/*.html")) |
| + } |
| pages["credits"] = template.Must(pages["credits"].ParseFS(credits.Templates(), "templates/credits.html")) |
| pages["home"] = template.Must(pages["home"].ParseFS(connect.Templates(), "templates/home_link.html")) |
| pages["signin_oauth"] = template.Must(pages["signin_oauth"].ParseFS(connect.Templates(), "templates/oauth.html")) |
diff --git a/internal/memoria/static/chrome.css b/internal/memoria/static/chrome.css| new file mode 100644 |
| index 0000000..63d7b3f |
| --- /dev/null |
| +++ b/internal/memoria/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/memoria/static/sky.css b/internal/memoria/static/sky.css| new file mode 100644 |
| index 0000000..50067a9 |
| --- /dev/null |
| +++ b/internal/memoria/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/memoria/templates/signin.html b/internal/memoria/templates/signin.html| index 7822e4f..242ac1c 100644 |
| --- a/internal/memoria/templates/signin.html |
| +++ b/internal/memoria/templates/signin.html |
| @@ -1,13 +1,8 @@ |
| {{define "title"}}Sign in — memoria{{end}} |
| -{{define "content"}} |
| -<h1>Sign in</h1> |
| -<p>memoria holds your team's work in one place. Sign in with your email and |
| -we'll send you a link.</p> |
| -{{if .Flash}}<div rst-callout rst-tone="{{.FlashKind | tone}}"><p rst-callout-body>{{.Flash}}</p></div>{{end}} |
| -<form method="post" action="/auth/begin" rst-stack> |
| - {{if .ForceEmail}}<input type="hidden" name="force" value="1">{{end}} |
| - <label for="address">Email address</label> |
| - <input type="email" id="address" name="address" required autocomplete="email" autofocus> |
| - <div rst-form-bar><button rst-btn="primary">Send me a link</button></div> |
| -</form> |
| -{{end}} |
| +{{/* The suite's door (estilo chrome): one card on the team's sky, the |
| + app's mark and name at its head. chrome.css draws the card, sky.css |
| + the sky; both are vendored from estilo (go run ./scripts/writeestilo). |
| + The layout's footer, and with it the egress claim, stays under it. */}} |
| +{{define "head"}}<link rel="stylesheet" href="{{asset "static/chrome.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/sky.css"}}">{{end}} |
| +{{define "content"}}{{with .Sent}}{{template "estilo-signin-sent" .}}{{else}}{{template "estilo-signin" .Door}}{{end}}{{end}} |
diff --git a/internal/memoria/templates/signin_oauth.html b/internal/memoria/templates/signin_oauth.html| index 7823ba5..a93182b 100644 |
| --- a/internal/memoria/templates/signin_oauth.html |
| +++ b/internal/memoria/templates/signin_oauth.html |
| @@ -4,7 +4,10 @@ |
| so the 303 was refused and sign-in with a Keymail address could not |
| leave the page. This page's meta refresh — and its link, for anyone |
| without it — is a navigation, not a form submission, so form-action |
| - does not apply. The markup is Home's shared template (Estilo |
| - homeconnect), rendered inside memoria's own layout as Sheets does. */}} |
| -{{define "head"}}{{template "home-oauth-head" .Data}}{{end}} |
| -{{define "content"}}{{template "home-oauth" .Data}}{{end}} |
| + does not apply. The refresh is Home's shared template (Estilo |
| + homeconnect); the body is the sign-in door's notice (estilo chrome), |
| + so the page between the door and Keymail is the same card. */}} |
| +{{define "head"}}{{template "home-oauth-head" .Data}} |
| +<link rel="stylesheet" href="{{asset "static/chrome.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/sky.css"}}">{{end}} |
| +{{define "content"}}{{template "estilo-signin-sent" .Door}}{{end}} |
diff --git a/internal/memoriatest/routes_test.go b/internal/memoriatest/routes_test.go| index 5548133..71ac5e2 100644 |
| --- a/internal/memoriatest/routes_test.go |
| +++ b/internal/memoriatest/routes_test.go |
| @@ -63,9 +63,13 @@ func TestNoUndeclaredRoutes(t *testing.T) { |
| "GET /static/credits.css": true, |
| "GET /static/": true, |
| "GET /static/estilo/fonts/v1/": true, |
| - "/": true, |
| - "/auth/": true, |
| - "POST /signout": true, |
| + // The sign-in door's sky layers: public artwork, no session, |
| + // the same bargain as the font. TestSkyRouteIsEstilosPrefix |
| + // keeps the literal equal to sky.Prefix. |
| + "GET /static/estilo/sky/v1/": true, |
| + "/": true, |
| + "/auth/": true, |
| + "POST /signout": true, |
| } |
| // Router method -> index of the pattern argument. Handle, HandleFunc, |
| // Mount and Route bind every method, so they read as ANY. |
diff --git a/internal/memoriatest/signin_door_test.go b/internal/memoriatest/signin_door_test.go| new file mode 100644 |
| index 0000000..35f865f |
| --- /dev/null |
| +++ b/internal/memoriatest/signin_door_test.go |
| @@ -0,0 +1,109 @@ |
| +package memoriatest |
| + |
| +import ( |
| + "html" |
| + "net/http" |
| + "net/url" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/estilo.git/sky" |
| +) |
| + |
| +// The sign-in page is the suite's door (estilo chrome, 2026-09-29): one |
| +// card on the team's sky with Memoria's mark and name at its head. Paul's |
| +// ruling is that every app's sign-in is "a version of the Oficina sign-in |
| +// screen", so this pins that the page IS the door rather than memoria's |
| +// old bare form — and that everything the door links is actually served: |
| +// a card whose stylesheet 404s, or a sky whose layers nothing mounts, |
| +// renders as a plain form with no error anywhere. |
| +// |
| +// And the door's form must still sign someone in. Its field is named by |
| +// estilo, not by this app, so the POST below uses the name the RENDERED |
| +// page carries, not one typed here: if the door ever renamed the field, |
| +// an owner's address would stop reaching the handler and this fails. |
| +// |
| +// MUTATION-TESTED 2026-09-29 by its author (so they prove consistency, not |
| +// coverage — AGENTS.md), each reverted: the sky mount removed from app.go |
| +// -> both served layers 404 here (and TestSkyRouteIsEstilosPrefix red); |
| +// the chrome.css link removed from signin.html -> "does not link chrome.css". |
| +func TestSigninPageIsTheDoor(t *testing.T) { |
| + h, rec := signinApp(t) |
| + rr := get(t, h, "/signin") |
| + if rr.Code != http.StatusOK { |
| + t.Fatalf("GET /signin = %d", rr.Code) |
| + } |
| + body := rr.Body.String() |
| + |
| + for _, want := range []string{ |
| + "estilo-door", |
| + `<span>Memoria</span>`, |
| + `<h1>Sign in</h1>`, |
| + "Memoria holds your team's work in one place.", |
| + `action="/auth/begin"`, |
| + `>Email me a link</button>`, |
| + // The layout's footer, and its egress claim, stays under the door. |
| + "<footer", |
| + "leaves this instance", |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Errorf("the sign-in page lacks %q", want) |
| + } |
| + } |
| + |
| + // Every sky layer the door links: the two inline placeholders (which |
| + // is why CSP must allow img-src data:) and the served ones. |
| + layers := regexp.MustCompile(`<img estilo-sky-layer="([a-z]+)"[^>]* src="([^"]+)"`).FindAllStringSubmatch(body, -1) |
| + if len(layers) < 3 { |
| + t.Fatalf("found %d sky layers on the sign-in page, want the placeholders and the served layers", len(layers)) |
| + } |
| + served := 0 |
| + for _, m := range layers { |
| + src := html.UnescapeString(m[2]) |
| + if strings.HasPrefix(src, "data:") { |
| + continue |
| + } |
| + served++ |
| + if !strings.HasPrefix(src, sky.Prefix) { |
| + t.Errorf("sky layer %s is %q, not under %s", m[1], src, sky.Prefix) |
| + } |
| + if got := get(t, h, src); got.Code != http.StatusOK || got.Header().Get("Content-Type") != "image/svg+xml" { |
| + t.Errorf("GET %s = %d %q; the sky layer is linked but not served", src, got.Code, got.Header().Get("Content-Type")) |
| + } |
| + } |
| + if served == 0 { |
| + t.Error("no sky layer is served from the app; the served half of the sky was never checked") |
| + } |
| + |
| + // The door's stylesheets are linked and served. |
| + for _, name := range []string{"chrome", "sky"} { |
| + m := regexp.MustCompile(`href="(/static/` + name + `[^"]*\.css)"`).FindStringSubmatch(body) |
| + if m == nil { |
| + t.Errorf("the sign-in page does not link %s.css", name) |
| + continue |
| + } |
| + if got := get(t, h, m[1]); got.Code != http.StatusOK || !strings.Contains(got.Body.String(), "estilo-") { |
| + t.Errorf("GET %s = %d; the door's stylesheet is linked but not served", m[1], got.Code) |
| + } |
| + } |
| + |
| + // The field name the door renders, read off the page. |
| + field := regexp.MustCompile(`<input rst-input type="email" id="estilo-signin-address" name="([^"]+)"`).FindStringSubmatch(body) |
| + if field == nil { |
| + t.Fatalf("no email field on the door:\n%s", body) |
| + } |
| + if field[1] != "address" { |
| + t.Errorf("the door's field is named %q; beginGuard and rastrillo read address", field[1]) |
| + } |
| + rr = post(t, h, "/auth/begin", url.Values{field[1]: {dialTestOwner}}) |
| + if rr.Code != http.StatusSeeOther || rr.Header().Get("Location") != "/signin?sent=1" { |
| + t.Errorf("an owner's POST from the door = %d %q, want 303 /signin?sent=1", rr.Code, rr.Header().Get("Location")) |
| + } |
| + // A stranger's POST answers the same, so the redirect alone cannot |
| + // tell a read address from an ignored one. The classifier dialling is |
| + // what proves the owner's address reached sign-in. |
| + if !rec.dialed() { |
| + t.Error("the owner's address never reached the sign-in classifier; the door's field is not what the handler reads") |
| + } |
| +} |
diff --git a/internal/memoriatest/signinoutcome_test.go b/internal/memoriatest/signinoutcome_test.go| index 7d7977c..9b32145 100644 |
| --- a/internal/memoriatest/signinoutcome_test.go |
| +++ b/internal/memoriatest/signinoutcome_test.go |
| @@ -66,11 +66,31 @@ func TestSigninPageSaysWhatHappened(t *testing.T) { |
| // Both writers of ?sent=1: rastrillo after mailing an owner's |
| // link, and beginGuard answering a stranger with the owner's |
| // answer by design. |
| + // |
| + // Since the sign-in door (2026-09-29) a sent link is the door's |
| + // own check-your-email notice (estilo-signin-sent), which REPLACES |
| + // the form rather than toning a flash above it — so the state |
| + // asserted is the notice, with no form under it. |
| + var bodies []string |
| for _, address := range []string{dialTestOwner, "stranger@attacker.example"} { |
| h, _ := signinApp(t) |
| rr := beginSignin(t, h, address) |
| body := followToSignin(t, h, rr, "sent=1") |
| - assertFlash(t, body, "positive", "Check your email") |
| + if !strings.Contains(body, `<p estilo-signin-body role="status">We've sent you a link to sign in.</p>`) { |
| + t.Errorf("the sent page is not the door's check-your-email notice:\n%s", body) |
| + } |
| + if !strings.Contains(body, "<h1>Check your email</h1>") { |
| + t.Error("the sent notice does not say check your email") |
| + } |
| + if strings.Contains(body, `action="/auth/begin"`) { |
| + t.Error("the sign-in form is still on the page after a link was sent") |
| + } |
| + bodies = append(bodies, body) |
| + } |
| + // Anti-oracle: an owner and a stranger land on the same page, |
| + // byte for byte. |
| + if bodies[0] != bodies[1] { |
| + t.Error("the sent page differs between an owner and a stranger; the page is an oracle for who the owners are") |
| } |
| }) |
diff --git a/internal/memoriatest/vendored_test.go b/internal/memoriatest/vendored_test.go| index dc3c996..7bde1cd 100644 |
| --- a/internal/memoriatest/vendored_test.go |
| +++ b/internal/memoriatest/vendored_test.go |
| @@ -2,6 +2,8 @@ package memoriatest |
| import ( |
| "amadan.net/oficina/estilo.git/busy" |
| + "amadan.net/oficina/estilo.git/chrome" |
| + "amadan.net/oficina/estilo.git/sky" |
| "bytes" |
| "os" |
| "path/filepath" |
| @@ -83,3 +85,36 @@ func TestBusyFilesAreEstilos(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// app.go mounts the sign-in door's sky at a literal pattern, for the same |
| +// reason as the font: the route gate reads literals. This keeps it equal |
| +// to estilo's versioned prefix, so a new sky version cannot leave the |
| +// door linking layers nothing serves. |
| +func TestSkyRouteIsEstilosPrefix(t *testing.T) { |
| + src, err := os.ReadFile(filepath.Join("..", "memoria", "app.go")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !bytes.Contains(src, []byte(`"GET `+sky.Prefix+`"`)) { |
| + t.Errorf("app.go does not mount the sky at %s", sky.Prefix) |
| + } |
| +} |
| + |
| +// The sign-in door (estilo chrome's estilo-signin and friends) is styled |
| +// by chrome.css and sky.css, vendored into static/ so they are served |
| +// fingerprinted like every other stylesheet. Pinned to the estilo this |
| +// app builds against; after an upgrade: go run ./scripts/writeestilo. |
| +func TestDoorStylesAreEstilos(t *testing.T) { |
| + for name, want := range map[string][]byte{ |
| + "chrome.css": chrome.CSS(), |
| + "sky.css": sky.CSS(), |
| + } { |
| + got, err := os.ReadFile(filepath.Join("..", "memoria", "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 0a5c347..a0a37b8 100644 |
| --- a/scripts/writeestilo/main.go |
| +++ b/scripts/writeestilo/main.go |
| @@ -1,7 +1,8 @@ |
| // Command writeestilo re-writes the files internal/memoria/static takes from |
| // the estilo this module pins: theme.css (Calma, with Memoria'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 two |
| +// stylesheets, chrome.css and sky.css. Run it from the repository root |
| // after upgrading estilo; the pin tests in internal/memoriatest 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/memoria/static/"+name, body, 0o644); err != nil { |
| panic(err) |