oficina / estilo Public

Merge branch 'signin-door' into main

chrome: the sign-in pages are the Oficina door

Paul: every app's sign-in should be a version of the Oficina sign-in
screen. estilo-signin and friends draw Home's door — a card on the
suite's sky with the app's mark and name, in its colour. Reviewed by
Claude Fable 5.1 (discussion 17).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 41201439fd22eaa219836934714b77e74b2503a4 parent 77346e8parent 09dfb01
4 files changed, +104 −19
  • chrome/chrome.css +34 −8
  • chrome/chrome.go +10 −1
  • chrome/chrome_test.go +31 −0
  • chrome/templates/signin.html +29 −10

CI passed — run details

CI log
=== modcache ===
all modules verified
=== vet ===
CGO_ENABLED=0 go vet ./...
=== fmt ===
=== build ===
CGO_ENABLED=0 go build -buildvcs=false ./...
=== go-test ===
CGO_ENABLED=0 go test -count=1 ./...
ok  	amadan.net/oficina/estilo.git/base	0.005s
ok  	amadan.net/oficina/estilo.git/brand	0.004s
ok  	amadan.net/oficina/estilo.git/busy	0.003s
ok  	amadan.net/oficina/estilo.git/callui	0.005s
ok  	amadan.net/oficina/estilo.git/chrome	0.181s
?   	amadan.net/oficina/estilo.git/cmd/oficina-notices	[no test files]
ok  	amadan.net/oficina/estilo.git/credits	0.007s
ok  	amadan.net/oficina/estilo.git/credits/catalog	0.006s
ok  	amadan.net/oficina/estilo.git/homeconnect	0.007s
ok  	amadan.net/oficina/estilo.git/internal/lint	0.008s
?   	amadan.net/oficina/estilo.git/internal/tokencheck	[no test files]
ok  	amadan.net/oficina/estilo.git/menu	0.009s
ok  	amadan.net/oficina/estilo.git/savestatus	0.074s
ok  	amadan.net/oficina/estilo.git/sky	0.023s
ok  	amadan.net/oficina/estilo.git/theme	0.008s
diff --git a/chrome/chrome.css b/chrome/chrome.css
index 4a3fe5d..1ff46e3 100644
--- a/chrome/chrome.css
+++ b/chrome/chrome.css
@@ -203,18 +203,44 @@
/* 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: one narrow,
- left-aligned column, not a card, so nothing in the flow is framed. */
+/* 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: 26rem;
- margin: 4rem auto var(--rst-sp-6);
- padding-inline: var(--rst-sp-4);
+ 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-icon] { display: block; }
-[estilo-signin] h1 { margin: var(--rst-sp-3) 0 0; font-size: 1.375rem; font-weight: 600; }
-[estilo-signin-lede] { margin: 0.25rem 0 var(--rst-sp-4); color: var(--rst-text-muted); }
+[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/chrome/chrome.go b/chrome/chrome.go
index 8bbf752..895441e 100644
--- a/chrome/chrome.go
+++ b/chrome/chrome.go
@@ -27,6 +27,7 @@ import (
"unicode"
"amadan.net/oficina/estilo.git/menu"
+ "amadan.net/oficina/estilo.git/sky"
"github.com/carlosframework/rastrillo/sessions"
)
@@ -196,7 +197,13 @@ type Hidden struct {
// SignIn is estilo-signin's data. Error is shown in a negative callout
// above the form; AddressError in the field's own error slot.
type SignIn struct {
- Icon, Heading, Lede string
+ Icon, Heading, Lede string
+ // App is the product's name, beside its mark at the head of the card
+ // ("Calendar"); Sky is the sky behind it, sky.For(<the instance's
+ // host>). Both optional. A Sky needs sky.css linked, sky.Handler
+ // mounted at sky.Prefix and img-src data: allowed (the template).
+ App string
+ Sky sky.View
Error string
Action, Return string
Hidden []Hidden
@@ -208,6 +215,8 @@ type SignIn struct {
// SignInNotice is the data of estilo-signin-sent and estilo-signed-out.
type SignInNotice struct {
Icon, Heading, Body string
+ App string
+ Sky sky.View
Link *Link
}
diff --git a/chrome/chrome_test.go b/chrome/chrome_test.go
index 97a312a..05913d6 100644
--- a/chrome/chrome_test.go
+++ b/chrome/chrome_test.go
@@ -13,6 +13,7 @@ import (
"amadan.net/oficina/estilo.git/internal/tokencheck"
"amadan.net/oficina/estilo.git/menu"
+ "amadan.net/oficina/estilo.git/sky"
"github.com/carlosframework/rastrillo/ui"
)
@@ -448,3 +449,33 @@ func TestScriptBehaviour(t *testing.T) {
t.Fatalf("node --test chrome.test.mjs: %v\n%s", err, out)
}
}
+
+// The door: the sign-in pages are one card on the suite's sky, headed by
+// the app's mark and name. Its sky markup must be exactly the sky
+// package's own, or the two would drift.
+func TestSignInIsTheOficinaDoor(t *testing.T) {
+ v := sky.For("calendar.oficina.works")
+ html := render(t, "estilo-signin", SignIn{Icon: "/i.svg", App: "Calendar", Sky: v, Heading: "Sign in", Action: "/signin", AddressLabel: "Email address", Submit: "Email me a link"})
+ var want bytes.Buffer
+ if err := template.Must(template.ParseFS(sky.Templates(), "templates/*.html")).ExecuteTemplate(&want, "estilo-sky", v); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(html, strings.TrimSpace(want.String())) {
+ t.Errorf("the door's sky differs from estilo-sky's:\n%s\nwant it to contain:\n%s", html, want.String())
+ }
+ for _, s := range []string{`<div estilo-door estilo-sky-host>`, `<section estilo-signin>`, `<span>Calendar</span>`, ` autofocus`, `</section>
+</div>`} {
+ if !strings.Contains(html, s) {
+ t.Errorf("door lacks %q", s)
+ }
+ }
+ for _, name := range []string{"estilo-signin-sent", "estilo-signed-out"} {
+ if h := render(t, name, SignInNotice{Icon: "/i.svg", App: "Calendar", Sky: v, Heading: "h", Body: "b"}); !strings.Contains(h, `<div estilo-door estilo-sky-host>`) || !strings.Contains(h, "estilo-sky-layer") {
+ t.Errorf("%s is not drawn as the door", name)
+ }
+ }
+ // No sky given, no sky drawn: the card still stands.
+ if h := render(t, "estilo-signin", SignIn{Icon: "/i.svg", Heading: "h", Action: "/s", AddressLabel: "a", Submit: "s"}); strings.Contains(h, "estilo-sky-layer") {
+ t.Error("a sign-in with no Sky drew one")
+ }
+}
diff --git a/chrome/templates/signin.html b/chrome/templates/signin.html
index 689cab0..b845801 100644
--- a/chrome/templates/signin.html
+++ b/chrome/templates/signin.html
@@ -6,8 +6,29 @@
ui.Templates() with ui.Funcs() alongside this package. The form sets
novalidate: an address error goes in the field's own error slot, never
the browser's bubble. Data: chrome.SignIn. */}}
-{{define "estilo-signin"}}<section estilo-signin>
- <img estilo-brand-icon="listing" src="{{.Icon}}" alt="" width="40" height="40">
+
+{{/* estilo-door-open / -close: the Oficina sign-in door around a card —
+ the suite's sky behind (sky.View in .Sky; none if it is empty) and the
+ app's own mark and name at the card's head (.App; the mark alone if
+ empty). The sky's markup is the same as the sky package's estilo-sky
+ template (chrome_test checks), repeated here so an app need not parse
+ the sky package's templates. An app that passes a Sky must also link
+ sky.css, mount sky.Handler at sky.Prefix, and allow img-src data: (the
+ placeholder is inline) — without sky.css the layers flow in the page. */}}
+{{define "estilo-door-open"}}<div estilo-door estilo-sky-host>
+{{- with .Sky.Layers}}
+<div estilo-sky aria-hidden="true">
+ {{- range .}}
+ <img estilo-sky-layer="{{.Name}}"{{with .Tone}} estilo-sky-tone="{{.}}"{{end}} src="{{with .Inline}}{{.}}{{else}}{{.Src}}{{end}}" alt=""{{if .Src}} decoding="async"{{end}}>
+ {{- end}}
+</div>
+{{- end}}
+<section estilo-signin>
+ <p estilo-brand><img estilo-brand-icon="listing" src="{{.Icon}}" alt="" width="36" height="36">{{with .App}}<span>{{.}}</span>{{end}}</p>
+{{- end}}
+{{define "estilo-door-close"}}</section>
+</div>{{end}}
+{{define "estilo-signin"}}{{template "estilo-door-open" .}}
<h1>{{.Heading}}</h1>
{{- with .Lede}}
<p estilo-signin-lede>{{.}}</p>
@@ -22,32 +43,30 @@
{{- range .Hidden}}
<input type="hidden" name="{{.Name}}" value="{{.Value}}">
{{- end}}
- {{template "field" (dict "ID" "estilo-signin-address" "Name" "address" "Type" "email" "Label" .AddressLabel "Autocomplete" "email" "Required" true "Value" .Address "Help" .Help "Error" .AddressError)}}
+ {{template "field" (dict "ID" "estilo-signin-address" "Name" "address" "Type" "email" "Label" .AddressLabel "Autocomplete" "email" "Required" true "Autofocus" true "Value" .Address "Help" .Help "Error" .AddressError)}}
<div rst-form-foot><button rst-btn="primary" type="submit">{{.Submit}}</button></div>
</form>
{{- with .OpenSource}}
<p estilo-signin-foot><a href="{{.Href}}">{{.Label}}</a></p>
{{- end}}
-</section>{{end}}
+{{template "estilo-door-close"}}{{end}}
{{/* estilo-signin-sent: replaces the form once a link has been sent. Link
goes back to the form, keeping the return. Data: chrome.SignInNotice. */}}
-{{define "estilo-signin-sent"}}<section estilo-signin>
- <img estilo-brand-icon="listing" src="{{.Icon}}" alt="" width="40" height="40">
+{{define "estilo-signin-sent"}}{{template "estilo-door-open" .}}
<h1>{{.Heading}}</h1>
<p estilo-signin-body role="status">{{.Body}}</p>
{{- with .Link}}
<p><a href="{{.Href}}">{{.Label}}</a></p>
{{- end}}
-</section>{{end}}
+{{template "estilo-door-close"}}{{end}}
{{/* estilo-signed-out: where Sign out lands. Link is the primary
"Sign in". Data: chrome.SignInNotice. */}}
-{{define "estilo-signed-out"}}<section estilo-signin>
- <img estilo-brand-icon="listing" src="{{.Icon}}" alt="" width="40" height="40">
+{{define "estilo-signed-out"}}{{template "estilo-door-open" .}}
<h1>{{.Heading}}</h1>
<p estilo-signin-body>{{.Body}}</p>
{{- with .Link}}
<p><a rst-btn="primary" href="{{.Href}}">{{.Label}}</a></p>
{{- end}}
-</section>{{end}}
+{{template "estilo-door-close"}}{{end}}