oficina / fichas Public

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>
Paul Campbell pushed by paul@keymail.dev 36d5e37c5c879b7191f9d22fc7ea1d34ddfa1f6c parent 9179761
13 files changed, +541 −47
  • go.mod +1 −1
  • go.sum +2 −0
  • internal/fichas/app.go +5 −0
  • internal/fichas/auth_navigation.go +1 −1
  • internal/fichas/handlers.go +56 −8
  • internal/fichas/render.go +24 −17
  • internal/fichas/static/chrome.css +240 −0
  • internal/fichas/static/sky.css +76 −0
  • internal/fichas/templates/signin.html +19 −19
  • internal/fichastest/signin_flow_test.go +21 −0
  • internal/fichastest/signin_test.go +69 −0
  • internal/fichastest/vendored_test.go +21 −0
  • scripts/writeestilo/main.go +6 −1
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 ef07972..daabd02 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"
@@ -108,6 +109,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 cc4f2f2..9ba49d7 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"
@@ -70,14 +72,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 684b29d..d7c7b3d 100644
--- a/internal/fichas/render.go
+++ b/internal/fichas/render.go
@@ -8,6 +8,7 @@ import (
"net/http"
"time"
+ "amadan.net/oficina/estilo.git/chrome"
"github.com/carlosframework/rastrillo"
"github.com/carlosframework/rastrillo/ui"
@@ -40,23 +41,29 @@ func init() {
"signin": "layout", "errors": "layout",
}
for name, shell := range shells {
- pages[name] = template.Must(
- template.Must(template.New("layout").
- Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))).
- Funcs(template.FuncMap{
- "asset": assets.Path,
- "options": selectOptions,
- "ago": ago,
- "dateAgo": dateAgo,
- "stageTone": stageTone,
- "isLong": isLong,
- "lower": lower,
- "commas": commas,
- "errFor": errFor,
- "yesNo": yesNo,
- }).
- ParseFS(ui.Templates(), "*.html")).
- ParseFS(appFS, "templates/"+shell+".html", "templates/partials.html", "templates/"+name+".html"))
+ t := template.Must(template.New("layout").
+ Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))).
+ Funcs(template.FuncMap{
+ "asset": assets.Path,
+ "options": selectOptions,
+ "ago": ago,
+ "dateAgo": dateAgo,
+ "stageTone": stageTone,
+ "isLong": isLong,
+ "lower": lower,
+ "commas": commas,
+ "errFor": errFor,
+ "yesNo": yesNo,
+ }).
+ 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"))
+ }
+ pages[name] = template.Must(t.ParseFS(appFS, "templates/"+shell+".html", "templates/partials.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)