oficina / memoria Public

Merge branch 'signin-door' into main

Merge signin-door: sign-in is the Oficina door
claude agent, operated by paul@keymail.dev aab9263567c3c6c6eede960024edf0f320d13f89 parent 45675e2parent 41a76c0
25 files changed, +697 −38
  • go.mod +1 −1
  • go.sum +4 −0
  • internal/memoria/app.go +6 −0
  • internal/memoria/auth.go +32 −1
  • internal/memoria/auth_test.go +16 −0
  • internal/memoria/handlers.go +59 −2
  • internal/memoria/home_render.go +17 −3
  • internal/memoria/home_router_test.go +23 −0
  • internal/memoria/render.go +16 −1
  • internal/memoria/static/chrome.css +246 −0
  • internal/memoria/static/sky.css +76 −0
  • internal/memoria/templates/add.html +1 −1
  • internal/memoria/templates/connectors.html +1 −1
  • internal/memoria/templates/credits.html +1 −1
  • internal/memoria/templates/document.html +1 −1
  • internal/memoria/templates/index.html +1 −1
  • internal/memoria/templates/layout.html +2 −2
  • internal/memoria/templates/notion.html +1 −1
  • internal/memoria/templates/signin.html +8 −13
  • 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 ./...
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/memoria/cmd/memoria	[no test files]
ok  	amadan.net/oficina/memoria/internal/memoria	6.464s
ok  	amadan.net/oficina/memoria/internal/memoria/chunk	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/fact	(cached)
?   	amadan.net/oficina/memoria/internal/memoria/icons	[no test files]
ok  	amadan.net/oficina/memoria/internal/memoria/index	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/notion	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/provider	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/query	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/seal	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/sibling	(cached)
ok  	amadan.net/oficina/memoria/internal/memoriatest	4.274s
?   	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..097f9c8 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-20260929185827-41201439fd22
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..979e90f 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,9 @@
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/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..3ea2aa1 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"
@@ -115,6 +117,14 @@ func (a *app) beginGuard(origin string, next http.HandlerFunc) http.HandlerFunc
// differently from the thing it guards is not guarding it. There
// is no error branch now, because FormValue has no error to
// branch on — the two calls cannot disagree.
+ // A shape check first, answered the same for everyone: the door's
+ // form leaves validation to the server, and "paul@" or an empty
+ // field should say so rather than claim a link was sent. It looks
+ // only at the text, never at who owns what, so it tells nothing.
+ if !plausibleAddress(r.FormValue("address")) {
+ http.Redirect(w, r, signinPath+"?err=address", http.StatusSeeOther)
+ return
+ }
if !a.isOwner(normaliseAddress(r.FormValue("address"))) {
// No address in the log line, ever: §3.2, gate 4.
a.logger.Info("sign-in refused before classifier dial")
@@ -243,14 +253,35 @@ 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
+}
+
+// plausibleAddress is the shape of an email address and nothing more:
+// something, an @, something, no spaces. The last @ splits it (a quoted
+// local part may hold one), and a dotless domain is still an address.
+func plausibleAddress(v string) bool {
+ v = strings.TrimSpace(v)
+ at := strings.LastIndexByte(v, '@')
+ return at > 0 && at < len(v)-1 && !strings.ContainsAny(v, " \t\r\n")
}
diff --git a/internal/memoria/auth_test.go b/internal/memoria/auth_test.go
new file mode 100644
index 0000000..5993d93
--- /dev/null
+++ b/internal/memoria/auth_test.go
@@ -0,0 +1,16 @@
+package memoria
+
+import "testing"
+
+func TestPlausibleAddressIsShapeOnly(t *testing.T) {
+ for v, want := range map[string]bool{
+ "paul@example.com": true, " paul@example.com ": true, "a@b.co": true,
+ "": false, "paul@": false, "@example.com": false, "paul": false,
+ "paul@localhost": true, `"a@b"@example.com`: true,
+ "pa ul@example.com": false, "paul@example.com@": false,
+ } {
+ if got := plausibleAddress(v); got != want {
+ t.Errorf("plausibleAddress(%q) = %v, want %v", v, got, want)
+ }
+ }
+}
diff --git a/internal/memoria/handlers.go b/internal/memoria/handlers.go
index 8d82ea0..808fec7 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"
@@ -1124,10 +1126,65 @@ 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, Sent and
+// SignedOut is set —
+// the form, the check-your-email notice that replaces it, or the page
+// Home sign-out lands on.
+type signinPage struct {
+ page
+ Door *chrome.SignIn
+ Sent *chrome.SignInNotice
+ SignedOut *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/home_render.go b/internal/memoria/home_render.go
index f57f506..c71ace2 100644
--- a/internal/memoria/home_render.go
+++ b/internal/memoria/home_render.go
@@ -1,9 +1,12 @@
package memoria
import (
- "amadan.net/oficina/home/connect"
"bytes"
"net/http"
+
+ "amadan.net/oficina/estilo.git/chrome"
+ "amadan.net/oficina/estilo.git/sky"
+ "amadan.net/oficina/home/connect"
)
type homePage struct {
@@ -12,9 +15,20 @@ type homePage struct {
}
func (a *app) renderHome(w http.ResponseWriter, r *http.Request, state, label string, status int) {
- p := homePage{page: page{Chunks: a.chunkCount(), ModelOn: a.modelOn()}, Home: connect.Page(state, label)}
+ pg := page{Chunks: a.chunkCount(), ModelOn: a.modelOn()}
+ data := connect.Page(state, label)
+ tree, view := pages["home"], any(homePage{page: pg, Home: data})
+ // Signed out of Home is the door: Home's heading, sentence and link,
+ // on Memoria's card and sky, like the sign-in page it sits beside.
+ if state == "signedout" {
+ tree, view = pages["signin"], signinPage{page: pg, SignedOut: &chrome.SignInNotice{
+ Icon: assets.Path("static/brand/icon.svg"), App: "Memoria", Sky: sky.For(r.Host),
+ Heading: data.Title, Body: data.Body,
+ Link: &chrome.Link{Href: data.Action, Label: data.ActionLabel},
+ }}
+ }
var body bytes.Buffer
- if err := pages["home"].ExecuteTemplate(&body, "layout", p); err != nil {
+ if err := tree.ExecuteTemplate(&body, "layout", view); err != nil {
http.Error(w, "something went wrong", http.StatusInternalServerError)
return
}
diff --git a/internal/memoria/home_router_test.go b/internal/memoria/home_router_test.go
index 1ab5119..6ade219 100644
--- a/internal/memoria/home_router_test.go
+++ b/internal/memoria/home_router_test.go
@@ -3,6 +3,7 @@ package memoria
import (
"bytes"
"context"
+ "html"
"io"
"log/slog"
"net/http"
@@ -375,3 +376,25 @@ func TestHomePagesStateTheModelSwitch(t *testing.T) {
}
}
}
+
+// Where Home sign-out lands is the door, like the sign-in page beside it:
+// Memoria's card on the sky, with Home's words and link, and the door's
+// stylesheets linked so the card is drawn.
+func TestSignedOutIsTheDoor(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ w := f.do("GET", "/signed-out", "")
+ if w.Code != http.StatusOK || w.Header().Get("Cache-Control") != "no-store" {
+ t.Fatalf("/signed-out: %d, Cache-Control %q", w.Code, w.Header().Get("Cache-Control"))
+ }
+ data := connect.Page("signedout", "")
+ b := html.UnescapeString(w.Body.String())
+ for _, want := range []string{
+ "<div estilo-door", "estilo-sky-layer", "<section estilo-signin", "<span>Memoria</span>",
+ "/static/chrome.", "/static/sky.", "<title>" + data.Title + " · Memoria</title>",
+ data.Title, data.Body, `href="` + data.Action + `"`, data.ActionLabel,
+ } {
+ if !strings.Contains(b, want) {
+ t.Errorf("/signed-out lacks %q", want)
+ }
+ }
+}
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..1ff46e3
--- /dev/null
+++ b/internal/memoria/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/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/add.html b/internal/memoria/templates/add.html
index 7af1689..2c5439e 100644
--- a/internal/memoria/templates/add.html
+++ b/internal/memoria/templates/add.html
@@ -1,4 +1,4 @@
-{{define "title"}}Add content — memoria{{end}}
+{{define "title"}}Add content — Memoria{{end}}
{{define "content"}}
{{/* Adding content, on its own page (2026-09-29, Paul: "remove the Add
forms from the index … that should be a separate Add content form").
diff --git a/internal/memoria/templates/connectors.html b/internal/memoria/templates/connectors.html
index 83e1060..0872f10 100644
--- a/internal/memoria/templates/connectors.html
+++ b/internal/memoria/templates/connectors.html
@@ -1,4 +1,4 @@
-{{define "title"}}Sources · memoria{{end}}
+{{define "title"}}Sources · Memoria{{end}}
{{define "content"}}
<h1>Sources</h1>
diff --git a/internal/memoria/templates/credits.html b/internal/memoria/templates/credits.html
index 9f026c3..c9d1e6d 100644
--- a/internal/memoria/templates/credits.html
+++ b/internal/memoria/templates/credits.html
@@ -1,4 +1,4 @@
-{{define "title"}}Open source · memoria{{end}}
+{{define "title"}}Open source · Memoria{{end}}
{{define "head"}}<link rel="stylesheet" href="/static/credits.css"><style>[rst-brand] { min-block-size: 24px; display: inline-flex; align-items: center; }</style>{{end}}
{{define "content"}}{{template "estilo/credits" .Data}}{{end}}
diff --git a/internal/memoria/templates/document.html b/internal/memoria/templates/document.html
index 50538e9..e80a57e 100644
--- a/internal/memoria/templates/document.html
+++ b/internal/memoria/templates/document.html
@@ -1,4 +1,4 @@
-{{define "title"}}{{.Title}} — memoria{{end}}
+{{define "title"}}{{.Title}} — Memoria{{end}}
{{define "content"}}
<nav rst-breadcrumb><a href="/">← Back to search</a></nav>
<h1>{{.Title}}</h1>
diff --git a/internal/memoria/templates/index.html b/internal/memoria/templates/index.html
index efd4d7f..02d824a 100644
--- a/internal/memoria/templates/index.html
+++ b/internal/memoria/templates/index.html
@@ -1,4 +1,4 @@
-{{define "title"}}memoria{{end}}
+{{define "title"}}Memoria{{end}}
{{define "content"}}
<form method="get" action="/" rst-search>
diff --git a/internal/memoria/templates/layout.html b/internal/memoria/templates/layout.html
index 9dafaa4..6d3b6d3 100644
--- a/internal/memoria/templates/layout.html
+++ b/internal/memoria/templates/layout.html
@@ -7,7 +7,7 @@
<link rel="stylesheet" href="{{asset "static/brand/brand.css"}}">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>{{block "title" .}}memoria{{end}}</title>
+<title>{{block "title" .}}Memoria{{end}}</title>
<link rel="stylesheet" href="/static/estilo/fonts/v1/fonts.css">
<link rel="stylesheet" href="{{asset "static/tokens.css"}}">
<link rel="stylesheet" href="{{asset "static/busy.css"}}">
@@ -34,7 +34,7 @@
</head>
<body>
<header rst-page-header>
- <a href="/" rst-brand estilo-brand><img estilo-brand-icon src="{{asset "static/brand/icon.svg"}}" alt="" width="28" height="28">memoria</a>
+ <a href="/" rst-brand estilo-brand><img estilo-brand-icon src="{{asset "static/brand/icon.svg"}}" alt="" width="28" height="28">Memoria</a>
{{if .Signed}}<nav rst-inline>
<details rst-dropdown name="rst-menus"><summary>Help</summary><div rst-dropdown-menu><a href="/help/open-source" referrerpolicy="no-referrer" rel="noreferrer">Open source</a></div></details>
{{if .HomeEnabled}}<a href="/account/home" rst-btn="ghost">Connect Home account</a>{{end}}
diff --git a/internal/memoria/templates/notion.html b/internal/memoria/templates/notion.html
index 83e8ae7..46c5278 100644
--- a/internal/memoria/templates/notion.html
+++ b/internal/memoria/templates/notion.html
@@ -1,4 +1,4 @@
-{{define "title"}}Notion · memoria{{end}}
+{{define "title"}}Notion · Memoria{{end}}
{{/* The status page refreshes itself ONLY while a run is active, and it
is a meta refresh because every affordance here works scriptless.
diff --git a/internal/memoria/templates/signin.html b/internal/memoria/templates/signin.html
index 7822e4f..7e3a5da 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}}
+{{define "title"}}{{with .SignedOut}}{{.Heading}} · Memoria{{else}}Sign in — Memoria{{end}}{{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 with .SignedOut}}{{template "estilo-signed-out" .}}{{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)