Sign-in is the Oficina door, from estilo chrome
Paul's ruling: every app's sign-in is a version of the Oficina sign-in screen — one card on the suite's sky, the app's own mark and name at its head, in the app's colour. estilo cc2c1e5 carries that as chrome's estilo-signin / estilo-signin-sent, so Fichas renders those instead of its own page-header form. All three states are the door: the form, the check-your-email notice, and the keymail continuation (which keeps its meta refresh and link, so form-action 'self' stays as strict as it was). Nothing about the flow changes. The field was already named address, which is what beginGuard and rastrillo read; the same POST, redirects, anti-oracle sent page, error texts, force=1 fallback and CSRF apply. The page now says "Fichas" rather than "fichas". chrome.css and sky.css are written into static/ by scripts/writeestilo and pinned to estilo by TestDoorFilesAreEstilos, like busy.css. The sky's SVG layers are served sessionless at sky.Prefix. The inline placeholder needs img-src data:, which rastrillo's default CSP allows; TestSigninPageCSPAllowsTheSky checks it through rastrillo.Handler.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
13 files changed,
+541
−47
go.mod+1 −1go.sum+2 −0internal/fichas/app.go+5 −0internal/fichas/auth_navigation.go+1 −1internal/fichas/handlers.go+56 −8internal/fichas/render.go+24 −17internal/fichas/static/chrome.css+240 −0internal/fichas/static/sky.css+76 −0internal/fichas/templates/signin.html+19 −19internal/fichastest/signin_flow_test.go+21 −0internal/fichastest/signin_test.go+69 −0internal/fichastest/vendored_test.go+21 −0scripts/writeestilo/main.go+6 −1
CI failed — run details
CI log
=== vet === go vet ./... go: downloading amadan.net/oficina/estilo.git v0.0.0-20260929183653-cc2c1e56fb4b internal/fichas/handlers.go:10:2: amadan.net/oficina/estilo.git@v0.0.0-20260929183653-cc2c1e56fb4b: invalid version: git fetch --unshallow -f --end-of-options origin in /home/paulca/.local/state/amadan/runner/repos/oficina__fichas/cache/go-mod/cache/vcs/bf4984e7a537af013cffcd0a654026b503a4e1566cddf740e3c37d2f6ae937e4: exit status 128: error: RPC failed; HTTP 429 curl 22 The requested URL returned error: 429 fatal: expected 'acknowledgments' internal/fichas/app.go:4:2: amadan.net/oficina/estilo.git@v0.0.0-20260929183653-cc2c1e56fb4b: invalid version: git fetch --unshallow -f --end-of-options origin in /home/paulca/.local/state/amadan/runner/repos/oficina__fichas/cache/go-mod/cache/vcs/bf4984e7a537af013cffcd0a654026b503a4e1566cddf740e3c37d2f6ae937e4: exit status 128: error: RPC failed; HTTP 429 curl 22 The requested URL returned error: 429 fatal: expected 'acknowledgments' internal/fichas/app.go:5:2: amadan.net/oficina/estilo.git@v0.0.0-20260929183653-cc2c1e56fb4b: invalid version: git fetch --unshallow -f --end-of-options origin in /home/paulca/.local/state/amadan/runner/repos/oficina__fichas/cache/go-mod/cache/vcs/bf4984e7a537af013cffcd0a654026b503a4e1566cddf740e3c37d2f6ae937e4: exit status 128: error: RPC failed; HTTP 429 curl 22 The requested URL returned error: 429 fatal: expected 'acknowledgments' scripts/writeestilo/main.go:12:2: amadan.net/oficina/estilo.git@v0.0.0-20260929183653-cc2c1e56fb4b: invalid version: git fetch --unshallow -f --end-of-options origin in /home/paulca/.local/state/amadan/runner/repos/oficina__fichas/cache/go-mod/cache/vcs/bf4984e7a537af013cffcd0a654026b503a4e1566cddf740e3c37d2f6ae937e4: exit status 128: error: RPC failed; HTTP 429 curl 22 The requested URL returned error: 429 fatal: expected 'acknowledgments' make: *** [Makefile:89: vet] Error 1 amadan: step vet failed: exit status 2 amadan: job failed at step vet — its output is above, not at the tail of this log
diff --git a/go.mod b/go.mod| index fade1b9..ca32b16 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 |
| github.com/carlosframework/rastrillo v0.23.0 |
| github.com/chromedp/chromedp v0.14.2 |
| github.com/go-chi/chi/v5 v5.3.2 |
diff --git a/go.sum b/go.sum| index ba71246..28cac4c 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -6,6 +6,8 @@ amadan.net/oficina/estilo.git v0.0.0-20260929095838-0994675f496e h1:LTSmIWUS8RjP |
| amadan.net/oficina/estilo.git v0.0.0-20260929095838-0994675f496e/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| amadan.net/oficina/estilo.git v0.0.0-20260929165053-846b39772f3c h1:YYocIz3r3b4oIA0K/58JkSsmyTOCzMT/2GaW/MFbbPo= |
| amadan.net/oficina/estilo.git v0.0.0-20260929165053-846b39772f3c/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| +amadan.net/oficina/estilo.git v0.0.0-20260929183653-cc2c1e56fb4b h1:UOxYBKsOSRHcHHYNKt0i6cB1Z4yT2soywOTWURYvBWo= |
| +amadan.net/oficina/estilo.git v0.0.0-20260929183653-cc2c1e56fb4b/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= |
| github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= |
| github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w= |
diff --git a/internal/fichas/app.go b/internal/fichas/app.go| index a89c721..9a40a3e 100644 |
| --- a/internal/fichas/app.go |
| +++ b/internal/fichas/app.go |
| @@ -1,6 +1,7 @@ |
| package fichas |
| import ( |
| + "amadan.net/oficina/estilo.git/sky" |
| "amadan.net/oficina/estilo.git/theme" |
| "context" |
| "log/slog" |
| @@ -97,6 +98,10 @@ func App(d *db.DB, cfg Config, logger *slog.Logger) (*http.ServeMux, *app, error |
| mux.Handle("GET /static/", assets.Handler()) |
| // Onest, Calma's typeface, served by estilo under its versioned prefix. |
| mux.Handle("GET "+theme.FontPrefix, theme.FontHandler()) |
| + // The suite's sky behind the sign-in door, drawn by estilo under its |
| + // versioned prefix. Sessionless like the rest of static, and more |
| + // specific than GET /static/, so it wins over the app's assets. |
| + mux.Handle("GET "+sky.Prefix, sky.Handler()) |
| mux.Handle("/", r) |
| return mux, a, nil |
| } |
diff --git a/internal/fichas/auth_navigation.go b/internal/fichas/auth_navigation.go| index 8f594a4..9ab63ff 100644 |
| --- a/internal/fichas/auth_navigation.go |
| +++ b/internal/fichas/auth_navigation.go |
| @@ -54,7 +54,7 @@ func (a *app) keymailContinuation(next http.HandlerFunc) http.HandlerFunc { |
| // The URL carries state and a PKCE challenge. It must not |
| // leak onward as a Referer. |
| w.Header().Set("Referrer-Policy", "no-referrer") |
| - render(w, "signin", map[string]any{"ContinueURL": destination}) |
| + render(w, "signin", signinPage{Continue: &signinContinue{Door: doorHead(r), ContinueURL: destination}}) |
| return |
| } |
diff --git a/internal/fichas/handlers.go b/internal/fichas/handlers.go| index a31aacd..7f06b3f 100644 |
| --- a/internal/fichas/handlers.go |
| +++ b/internal/fichas/handlers.go |
| @@ -7,6 +7,8 @@ import ( |
| "strconv" |
| "strings" |
| + "amadan.net/oficina/estilo.git/chrome" |
| + "amadan.net/oficina/estilo.git/sky" |
| "github.com/carlosframework/rastrillo/auth" |
| "github.com/carlosframework/rastrillo/db" |
| "github.com/go-chi/chi/v5" |
| @@ -67,14 +69,60 @@ func (a *app) actor(r *http.Request) (Actor, error) { |
| // refused in one place rather than panicking in six. |
| func (a *app) signin(w http.ResponseWriter, r *http.Request) { |
| q := r.URL.Query() |
| - render(w, "signin", map[string]any{ |
| - "Sent": q.Get("sent") == "1", |
| - "Error": signinError(q.Get("err")), |
| - // rastrillo sends a failed keymail round trip back with |
| - // force=1&err=keymail so the page can offer plain email instead; |
| - // the hidden field makes the next submission skip keymail. |
| - "ForceEmail": q.Get("force") == "1", |
| - }) |
| + head := doorHead(r) |
| + if q.Get("sent") == "1" { |
| + // The same page for an owner and a stranger (beginGuard), so it |
| + // promises nothing about the address. |
| + render(w, "signin", signinPage{Sent: &chrome.SignInNotice{ |
| + Icon: head.Icon, App: head.App, Sky: head.Sky, |
| + Heading: "Check your email", |
| + Body: "If that address can sign in here, a link is on its way. The link works once and expires.", |
| + Link: &chrome.Link{Href: signinPath, Label: "Use a different address"}, |
| + }}) |
| + return |
| + } |
| + form := chrome.SignIn{ |
| + Icon: head.Icon, App: head.App, Sky: head.Sky, |
| + Heading: "Sign in", |
| + Error: signinError(q.Get("err")), |
| + Action: "/auth/begin", |
| + AddressLabel: "Email address", |
| + Help: "Only people this instance already knows can sign in.", |
| + Submit: "Email me a link", |
| + } |
| + // rastrillo sends a failed keymail round trip back with |
| + // force=1&err=keymail so the page can offer plain email instead; |
| + // the hidden field makes the next submission skip keymail. |
| + if q.Get("force") == "1" { |
| + form.Hidden = []chrome.Hidden{{Name: "force", Value: "1"}} |
| + } |
| + render(w, "signin", signinPage{Form: &form}) |
| +} |
| + |
| +// signinPage is templates/signin.html's data: exactly one of its fields |
| +// is set. The page is the suite's door (estilo chrome) in every state — |
| +// the form, the check-your-email notice and the keymail continuation. |
| +type signinPage struct { |
| + Form *chrome.SignIn |
| + Sent *chrome.SignInNotice |
| + Continue *signinContinue |
| +} |
| + |
| +// signinContinue is the keymail continuation (auth_navigation.go): the |
| +// door's head, and the validated authorize URL to navigate to. |
| +type signinContinue struct { |
| + Door chrome.SignInNotice |
| + ContinueURL string |
| +} |
| + |
| +// doorHead is what every state of the door shares: Fichas' mark and name |
| +// at the card's head, on the suite's sky for this instance. |
| +func doorHead(r *http.Request) chrome.SignInNotice { |
| + return chrome.SignInNotice{ |
| + Icon: assets.Path("static/brand/icon.svg"), |
| + App: "Fichas", |
| + Sky: sky.For(r.Host), |
| + } |
| } |
| func (a *app) authBegin(w http.ResponseWriter, r *http.Request) { a.authOr500(w, r, authBegin) } |
diff --git a/internal/fichas/render.go b/internal/fichas/render.go| index c30ce8a..104430a 100644 |
| --- a/internal/fichas/render.go |
| +++ b/internal/fichas/render.go |
| @@ -6,6 +6,7 @@ import ( |
| "html/template" |
| "net/http" |
| + "amadan.net/oficina/estilo.git/chrome" |
| "github.com/carlosframework/rastrillo" |
| "github.com/carlosframework/rastrillo/ui" |
| @@ -27,23 +28,29 @@ var pages = map[string]*template.Template{} |
| func init() { |
| for _, name := range []string{"index", "errors", "card_new", "card_show", "card_delete", "signin"} { |
| - pages[name] = template.Must( |
| - // ui's partials and their helpers first, so every page can |
| - // call one without registering anything. WithIcons points |
| - // ui's two icon seams at the app's own set |
| - // (internal/fichas/icons, scaffolded by rastrillo new) — |
| - // both of them, always: passing only icon would leave |
| - // iconAssets on the framework default and silently break |
| - // cdn and js delivery. iconAssets is whatever <head> markup |
| - // the chosen delivery needs, empty for the vendored-inline |
| - // default, so the layout calls it unconditionally and |
| - // switching delivery later needs no template edit. |
| - template.Must(template.New("layout"). |
| - Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))). |
| - Funcs(template.FuncMap{"asset": assets.Path, "options": selectOptions}). |
| - ParseFS(ui.Templates(), "*.html")). |
| - // Then the app's own: the shell and this page's body. |
| - ParseFS(appFS, "templates/layout.html", "templates/"+name+".html")) |
| + // ui's partials and their helpers first, so every page can |
| + // call one without registering anything. WithIcons points |
| + // ui's two icon seams at the app's own set |
| + // (internal/fichas/icons, scaffolded by rastrillo new) — |
| + // both of them, always: passing only icon would leave |
| + // iconAssets on the framework default and silently break |
| + // cdn and js delivery. iconAssets is whatever <head> markup |
| + // the chosen delivery needs, empty for the vendored-inline |
| + // default, so the layout calls it unconditionally and |
| + // switching delivery later needs no template edit. |
| + t := template.Must(template.New("layout"). |
| + Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))). |
| + Funcs(template.FuncMap{"asset": assets.Path, "options": selectOptions}). |
| + ParseFS(ui.Templates(), "*.html")) |
| + if name == "signin" { |
| + // The sign-in page is the suite's door, estilo chrome's |
| + // estilo-signin and friends, which call ui's field and |
| + // callout: same tree, and before the app's own files so |
| + // the app's templates are the last word on any name. |
| + t = template.Must(t.ParseFS(chrome.Templates(), "templates/*.html")) |
| + } |
| + // Then the app's own: the shell and this page's body. |
| + pages[name] = template.Must(t.ParseFS(appFS, "templates/layout.html", "templates/"+name+".html")) |
| } |
| } |
diff --git a/internal/fichas/static/chrome.css b/internal/fichas/static/chrome.css| new file mode 100644 |
| index 0000000..63d7b3f |
| --- /dev/null |
| +++ b/internal/fichas/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/fichas/static/sky.css b/internal/fichas/static/sky.css| new file mode 100644 |
| index 0000000..50067a9 |
| --- /dev/null |
| +++ b/internal/fichas/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/fichas/templates/signin.html b/internal/fichas/templates/signin.html| index 56039a1..7be29e5 100644 |
| --- a/internal/fichas/templates/signin.html |
| +++ b/internal/fichas/templates/signin.html |
| @@ -1,27 +1,27 @@ |
| -{{define "content"}} |
| -{{template "page-header" (dict "Title" "Sign in to fichas")}} |
| +{{define "title"}}Sign in · Fichas{{end}} |
| + |
| +{{/* The door's stylesheets, only here: no other page draws it. */}} |
| +{{define "head"}} |
| +<link rel="stylesheet" href="{{asset "static/sky.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/chrome.css"}}"> |
| +{{end}} |
| -{{if .ContinueURL}} |
| +{{/* Every state is the suite's door (estilo chrome): one card on the sky, |
| + Fichas' mark and name at its head. Data: signinPage (handlers.go). */}} |
| +{{define "content"}} |
| +{{with .Continue}} |
| {{/* The keymail continuation. A document navigating is not the sign-in |
| form's redirect chain, so form-action 'self' stays as strict as it |
| was. The meta refresh needs no script; the link is the fallback. */}} |
| <meta http-equiv="refresh" content="0;url={{.ContinueURL}}"> |
| - <p>Continuing to keymail to confirm it's you.</p> |
| + {{template "estilo-door-open" .Door}} |
| + <h1>Continuing to keymail</h1> |
| + <p estilo-signin-body>Continuing to keymail to confirm it's you.</p> |
| <p><a rst-btn="primary" href="{{.ContinueURL}}">Continue to keymail</a></p> |
| -{{else if .Sent}} |
| - {{template "callout" (dict |
| - "Tone" "positive" |
| - "Title" "Check your email" |
| - "Body" "If that address can sign in here, a link is on its way. The link works once and expires.")}} |
| + {{template "estilo-door-close"}} |
| +{{else}}{{with .Sent}} |
| + {{template "estilo-signin-sent" .}} |
| {{else}} |
| - {{with .Error}}{{template "callout" (dict "Tone" "negative" "Title" "That didn't work" "Body" .)}}{{end}} |
| - <form method="post" action="/auth/begin"> |
| - {{if .ForceEmail}}<input type="hidden" name="force" value="1">{{end}} |
| - {{template "field" (dict |
| - "ID" "address" "Name" "address" "Label" "Your email address" |
| - "Type" "email" "Autocomplete" "email" "Required" true "Autofocus" true |
| - "Help" "Only people this instance already knows can sign in.")}} |
| - {{template "form-foot" (dict "Submit" "Email me a link")}} |
| - </form> |
| -{{end}} |
| + {{template "estilo-signin" .Form}} |
| +{{end}}{{end}} |
| {{end}} |
diff --git a/internal/fichastest/signin_flow_test.go b/internal/fichastest/signin_flow_test.go| index dbc9fee..e075e19 100644 |
| --- a/internal/fichastest/signin_flow_test.go |
| +++ b/internal/fichastest/signin_flow_test.go |
| @@ -191,3 +191,24 @@ func TestSigninErrorsArePlainLanguage(t *testing.T) { |
| t.Error("after a keymail failure the form does not fall back to email") |
| } |
| } |
| + |
| +// The door's field is named address, and the POST it makes still signs a |
| +// person in: an owner's address= gets the sent page and a link; the |
| +// submission is exactly what the door's form sends, force included after |
| +// a keymail failure. |
| +func TestTheDoorsFormStillSignsIn(t *testing.T) { |
| + h, mailer, _ := wiredApp(t) |
| + form := get(t, h, "/signin?force=1&err=keymail").Body.String() |
| + for _, want := range []string{`name="address"`, `name="force" value="1"`, `action="/auth/begin"`} { |
| + if !strings.Contains(form, want) { |
| + t.Fatalf("the door's form lacks %q:\n%s", want, form) |
| + } |
| + } |
| + rec := post(t, h, "/auth/begin", url.Values{"address": {TestOwner}, "force": {"1"}}) |
| + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" { |
| + t.Fatalf("POST address=: status %d, Location %q", rec.Code, rec.Header().Get("Location")) |
| + } |
| + if mail := mailer.last(t); !linkToken.MatchString(mail) { |
| + t.Fatalf("no sign-in link was sent:\n%s", mail) |
| + } |
| +} |
diff --git a/internal/fichastest/signin_test.go b/internal/fichastest/signin_test.go| index ca78c5a..35fff07 100644 |
| --- a/internal/fichastest/signin_test.go |
| +++ b/internal/fichastest/signin_test.go |
| @@ -6,10 +6,12 @@ import ( |
| "net/http" |
| "net/url" |
| "path/filepath" |
| + "regexp" |
| "strings" |
| "testing" |
| fichas "amadan.net/oficina/fichas/internal/fichas" |
| + "github.com/carlosframework/rastrillo" |
| "github.com/carlosframework/rastrillo/db" |
| ) |
| @@ -100,3 +102,70 @@ func TestTheGuardSeesAQueryStringAddress(t *testing.T) { |
| t.Errorf("redirected to %q", got) |
| } |
| } |
| + |
| +// The sign-in page is the suite's door (estilo chrome): one card on the |
| +// Oficina sky, Fichas' own mark and name at its head, styled by the |
| +// chrome.css and sky.css static/ carries. The email field is named |
| +// address, which is what beginGuard and rastrillo both read. |
| +func TestSigninPageIsTheDoor(t *testing.T) { |
| + h := productionApp(t) |
| + body := get(t, h, "/signin").Body.String() |
| + for _, want := range []string{ |
| + "estilo-door", |
| + "<span>Fichas</span>", |
| + `<img estilo-sky-layer="grain"`, |
| + `<img estilo-brand-icon="listing" src="/static/brand/icon.`, |
| + `<form rst-form estilo-signin-form method="post" action="/auth/begin"`, |
| + `name="address"`, |
| + "<h1>Sign in</h1>", |
| + "Email address", |
| + "Only people this instance already knows can sign in.", |
| + "Email me a link", |
| + `href="/static/chrome.`, |
| + `href="/static/sky.`, |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Errorf("the sign-in page lacks %q:\n%s", want, body) |
| + } |
| + } |
| + |
| + // The sky's layers are served, sessionless, from estilo's prefix. |
| + src := regexp.MustCompile(`estilo-sky-layer="filaments"[^>]*src="([^"]+)"`).FindStringSubmatch(body) |
| + if src == nil { |
| + t.Fatal("no filaments layer on the sign-in page") |
| + } |
| + layer := get(t, h, src[1]) |
| + if layer.Code != http.StatusOK || layer.Header().Get("Content-Type") != "image/svg+xml" { |
| + t.Errorf("GET %s: status %d, type %q", src[1], layer.Code, layer.Header().Get("Content-Type")) |
| + } |
| + |
| + // The check-your-email state is the door too. |
| + sent := get(t, h, "/signin?sent=1").Body.String() |
| + for _, want := range []string{"estilo-door", "<span>Fichas</span>", "Check your email", "If that address can sign in here"} { |
| + if !strings.Contains(sent, want) { |
| + t.Errorf("the sent page lacks %q:\n%s", want, sent) |
| + } |
| + } |
| + if strings.Contains(sent, `name="address"`) { |
| + t.Error("the sent page still shows the form") |
| + } |
| +} |
| + |
| +// The door's sky draws its first frame from inline data: images, so the |
| +// policy the page is actually served under must allow them. Built through |
| +// rastrillo.Handler, the same assembly Serve uses, so the CSP is real. |
| +func TestSigninPageCSPAllowsTheSky(t *testing.T) { |
| + served, closeDB, err := rastrillo.Handler(rastrillo.Options{Mux: productionApp(t).(*http.ServeMux), ErrorPage: fichas.ErrorPage}) |
| + if err != nil { |
| + t.Fatalf("rastrillo.Handler: %v", err) |
| + } |
| + t.Cleanup(func() { closeDB() }) |
| + rec := get(t, served, "/signin") |
| + csp := rec.Header().Get("Content-Security-Policy") |
| + if !strings.Contains(csp, "img-src 'self' data:") { |
| + t.Errorf("CSP %q does not allow the sky's inline placeholder", csp) |
| + } |
| + if !strings.Contains(rec.Body.String(), `src="data:image/`) { |
| + t.Error("the sky's placeholder is not inline") |
| + } |
| +} |
diff --git a/internal/fichastest/vendored_test.go b/internal/fichastest/vendored_test.go| index 5774872..792bc19 100644 |
| --- a/internal/fichastest/vendored_test.go |
| +++ b/internal/fichastest/vendored_test.go |
| @@ -2,6 +2,8 @@ package fichastest |
| import ( |
| "amadan.net/oficina/estilo.git/busy" |
| + "amadan.net/oficina/estilo.git/chrome" |
| + "amadan.net/oficina/estilo.git/sky" |
| "amadan.net/oficina/estilo.git/theme" |
| "bytes" |
| "os" |
| @@ -97,3 +99,22 @@ func TestBusyFilesAreEstilos(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// The sign-in door (estilo chrome's estilo-signin and friends) is styled |
| +// by chrome.css and sky.css, which static/ carries as estilo does and the |
| +// sign-in page links. Pinned here to the estilo this app builds against. |
| +// After an estilo upgrade: go run ./scripts/writeestilo. |
| +func TestDoorFilesAreEstilos(t *testing.T) { |
| + for name, want := range map[string][]byte{ |
| + "chrome.css": chrome.CSS(), |
| + "sky.css": sky.CSS(), |
| + } { |
| + got, err := os.ReadFile(filepath.Join("..", "fichas", "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 0cd5d81..6f23894 100644 |
| --- a/scripts/writeestilo/main.go |
| +++ b/scripts/writeestilo/main.go |
| @@ -1,7 +1,8 @@ |
| // Command writeestilo re-writes the files internal/fichas/static takes from |
| // the estilo this module pins: theme.css (Calma, with Fichas'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/fichastest 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/fichas/static/"+name, body, 0o644); err != nil { |
| panic(err) |