oficina / memoria Public

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>
Paul Campbell pushed by paul@keymail.dev acb1f923bef45254646f0b4bd9dca14c18f7d7af parent cd4856a
15 files changed, +604 −26
  • go.mod +1 −1
  • go.sum +2 −0
  • internal/memoria/app.go +6 −0
  • internal/memoria/auth.go +15 −1
  • internal/memoria/handlers.go +56 −2
  • internal/memoria/render.go +16 −1
  • internal/memoria/static/chrome.css +240 −0
  • internal/memoria/static/sky.css +76 −0
  • internal/memoria/templates/signin.html +7 −12
  • internal/memoria/templates/signin_oauth.html +7 −4
  • internal/memoriatest/routes_test.go +7 −3
  • internal/memoriatest/signin_door_test.go +109 −0
  • internal/memoriatest/signinoutcome_test.go +21 −1
  • internal/memoriatest/vendored_test.go +35 −0
  • scripts/writeestilo/main.go +6 −1

CI passed — run details

CI log
=== vet ===
go vet ./...
go: downloading amadan.net/oficina/estilo.git v0.0.0-20260929183653-cc2c1e56fb4b
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/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&#39;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&#39;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)