oficina / estilo Public

Clone
git clone https://amadan.net/oficina/estilo

Plain git — no account needed to clone.

Download

Download this file

1package chrome
2
3import (
4 "bytes"
5 "html/template"
6 "net/http"
7 "net/http/httptest"
8 "net/url"
9 "os/exec"
10 "regexp"
11 "strings"
12 "testing"
13
14 "amadan.net/oficina/estilo.git/internal/tokencheck"
15 "amadan.net/oficina/estilo.git/menu"
16 "amadan.net/oficina/estilo.git/sky"
17 "github.com/carlosframework/rastrillo/ui"
18)
19
20func TestChromeUsesDefinedTokens(t *testing.T) {
21 missing, n := tokencheck.Undefined(CSS())
22 if n == 0 {
23 t.Fatal("chrome.css reads no rastrillo token; the check would pass vacuously")
24 }
25 if len(missing) > 0 {
26 t.Fatal("chrome.css reads undefined tokens:", missing)
27 }
28}
29
30// The bar's height is public contract and must be fixed, never a minimum:
31// nothing inside it may grow it.
32func TestBarHeightIsFixedAndPublic(t *testing.T) {
33 src := string(CSS())
34 for _, want := range []string{
35 "--estilo-bar-block-size: 3.25rem;",
36 "--estilo-z-bar: 45;",
37 "block-size: var(--estilo-bar-block-size);",
38 "flex-wrap: nowrap;",
39 } {
40 if !strings.Contains(src, want) {
41 t.Errorf("chrome.css lost %q", want)
42 }
43 }
44 // Narrow: the title field may shrink to nothing and the Rename button
45 // floats under it rather than taking room from Menu and the switcher.
46 // The browser proof is polish-audit/r2-chrome-check.mjs's reach checks.
47 narrow := src[strings.LastIndex(src, "@media (max-width: 799.98px)"):]
48 for _, want := range []string{"[estilo-bar-title-input] { min-inline-size: 0; }", "[estilo-bar-title]:focus-within [estilo-bar-title-submit] {\n position: absolute;"} {
49 if !strings.Contains(narrow, want) {
50 t.Errorf("the narrow title layout lost %q", want)
51 }
52 }
53 if regexp.MustCompile(`\[estilo-bar\]\s*\{[^}]*min-block-size`).MatchString(src) {
54 t.Error("the bar declares a min-block-size: it could grow")
55 }
56}
57
58func parse(t *testing.T) *template.Template {
59 t.Helper()
60 view := template.New("").Funcs(ui.Funcs())
61 view, err := view.ParseFS(ui.Templates(), "*.html")
62 if err != nil {
63 t.Fatal(err)
64 }
65 if view, err = view.ParseFS(menu.Templates(), "templates/*.html"); err != nil {
66 t.Fatal(err)
67 }
68 if view, err = view.ParseFS(Templates(), "templates/*.html"); err != nil {
69 t.Fatal(err)
70 }
71 return view
72}
73
74func render(t *testing.T, name string, data any) string {
75 t.Helper()
76 var out bytes.Buffer
77 if err := parse(t).ExecuteTemplate(&out, name, data); err != nil {
78 t.Fatal(err)
79 }
80 return out.String()
81}
82
83// One word per locale, used for every caller-supplied label, so a label
84// the template dropped or hard-coded shows up as a missing string.
85var locales = map[string]string{"en": "Account", "de": "Konto", "es": "Cuenta", "fr": "Compte", "it": "Account", "pt": "Conta", "nl": "Account", "pl": "Konto", "ar": "الحساب", "ja": "アカウント", "zh": "账户", "ko": "계정"}
86
87func signedIn(label string) Bar {
88 return Bar{
89 App: App{Name: label + "-app", Slug: "docs", Home: "/", Icon: "/static/estilo/brand/docs.svg"},
90 ContextEdit: &TitleEdit{Action: "/d/abc/title", Name: "title", Value: label + "-title", Label: label + "-title-label", Submit: label + "-rename"},
91 Nav: []Link{{Href: "/", Label: label + "-nav", Current: true}},
92 NavLabel: label + "-navlabel",
93 Actions: []Action{
94 {Item: menu.Item{Href: "/d/abc/share", Label: label + "-share"}},
95 {Item: menu.Item{Action: "/d/new", Label: label + "-create"}, Primary: true},
96 },
97 Switcher: &Switcher{Label: label + "-apps", AllApps: menu.Item{Href: "https://oficina.example/", Label: label + "-allapps"}},
98 Account: &Account{
99 TriggerLabel: label + "-trigger", Initials: "P", Address: "paul@example.test",
100 Labels: AccountLabels{SignedInAs: label + "-signedinas", Appearance: label + "-appearance", Help: label + "-help"},
101 Items: []menu.Item{{Href: "/account/home", Label: label + "-connect"}},
102 Appearance: &AppearanceMenu{Action: "/appearance", Return: "/d/abc", Options: AppearanceOptions(Dark, label+"-light", label+"-dark", label+"-system")},
103 Help: []menu.Item{{Href: "/credits", Label: label + "-opensource"}},
104 SignOut: SignOut{Action: "/signout", Label: label + "-signout"},
105 },
106 Labels: BarLabels{Skip: label + "-skip", Menu: label + "-menu"},
107 }
108}
109
110func TestBarRendersCallerLabelsInEveryLocale(t *testing.T) {
111 for locale, label := range locales {
112 t.Run(locale, func(t *testing.T) {
113 html := render(t, "estilo-bar", signedIn(label))
114 esc := template.HTMLEscapeString
115 for _, want := range []string{
116 `<a estilo-bar-brand href="/">`, esc(label) + "-app",
117 `<form estilo-bar-title method="post" action="/d/abc/title">`, `value="` + esc(label) + `-title"`, `aria-label="` + esc(label) + `-title-label"`, esc(label) + "-rename",
118 `aria-label="` + esc(label) + `-navlabel"`, `aria-current="page"`,
119 esc(label) + "-share", esc(label) + "-create", esc(label) + "-menu",
120 `aria-label="` + esc(label) + `-apps"`, esc(label) + "-allapps",
121 `aria-label="` + esc(label) + `-trigger"`, esc(label) + "-signedinas", "paul@example.test",
122 esc(label) + "-connect", esc(label) + "-appearance", esc(label) + "-light", esc(label) + "-dark", esc(label) + "-system",
123 esc(label) + "-help", esc(label) + "-opensource", esc(label) + "-signout",
124 } {
125 if !strings.Contains(html, want) {
126 t.Fatalf("missing %q", want)
127 }
128 }
129 if strings.Contains(html, "<script") {
130 t.Fatal("the bar requires no script")
131 }
132 })
133 }
134}
135
136// The "same corner" rule: the account is the last element in the end
137// group, after actions, Menu and the switcher, in that order.
138func TestEndGroupOrderIsFixed(t *testing.T) {
139 html := render(t, "estilo-bar", signedIn("x"))
140 order := []string{"<div estilo-bar-actions>", "<details estilo-bar-more", "<details estilo-switcher", "<details estilo-account"}
141 last := -1
142 for _, marker := range order {
143 i := strings.Index(html, marker)
144 if i < 0 || i < last {
145 t.Fatalf("%q is missing or out of order", marker)
146 }
147 last = i
148 }
149 tail := html[strings.LastIndex(html, "</details>"):]
150 if strings.Count(tail, "<") != 3 { // </details> </div> </header>
151 t.Fatalf("something follows the account in the end group: %q", tail)
152 }
153}
154
155// Every top-level trigger shares one exclusivity group, so opening one
156// closes the others; nothing nested inside a panel carries the name.
157func TestEveryBarTriggerJoinsTheOneMenuGroup(t *testing.T) {
158 html := render(t, "estilo-bar", signedIn("x"))
159 details := regexp.MustCompile(`<details [^>]*>`).FindAllString(html, -1)
160 if len(details) != 3 {
161 t.Fatalf("want three bar disclosures, got %d", len(details))
162 }
163 for _, d := range details {
164 if !strings.Contains(d, `name="estilo-menus"`) {
165 t.Errorf("%s is not in the estilo-menus group", d)
166 }
167 }
168}
169
170func TestSignOutAppearsExactlyOnce(t *testing.T) {
171 html := render(t, "estilo-bar", signedIn("x"))
172 if n := strings.Count(html, "x-signout"); n != 1 {
173 t.Fatalf("Sign out rendered %d times", n)
174 }
175}
176
177func TestAppearancePressedStateOnEveryOption(t *testing.T) {
178 html := render(t, "estilo-bar", signedIn("x"))
179 pressed := regexp.MustCompile(`value="(light|dark|system)" aria-pressed="(true|false)"`).FindAllStringSubmatch(html, -1)
180 if len(pressed) != 3 {
181 t.Fatalf("want three appearance options with aria-pressed, got %v", pressed)
182 }
183 for _, p := range pressed {
184 if (p[1] == "dark") != (p[2] == "true") {
185 t.Errorf("%s pressed=%s, want only dark pressed", p[1], p[2])
186 }
187 }
188 if !strings.Contains(html, `role="group" aria-labelledby="estilo-appearance"`) {
189 t.Error("the appearance options are not a labelled group")
190 }
191}
192
193func TestSignedOutCornerAndEmptyCorner(t *testing.T) {
194 b := Bar{App: App{Name: "Docs", Home: "/", Icon: "/i.svg"}, SignIn: &Link{Href: "/signin?return_to=%2Fd%2Fabc", Label: "Sign in"}}
195 html := render(t, "estilo-bar", b)
196 if !strings.Contains(html, `<a rst-btn href="/signin?return_to=%2Fd%2Fabc" estilo-bar-signin>Sign in</a>`) {
197 t.Fatal("signed-out corner missing", html)
198 }
199 if strings.Contains(html, "estilo-account") || strings.Contains(html, "estilo-bar-more") {
200 t.Fatal("signed-out bar has an account or Menu", html)
201 }
202 b.SignIn = nil
203 if html = render(t, "estilo-bar", b); strings.Contains(html, "estilo-bar-signin") {
204 t.Fatal("sign-in page bar shows Sign in", html)
205 }
206}
207
208func TestPublicBrandIsPlainText(t *testing.T) {
209 html := render(t, "estilo-bar", Bar{App: App{Name: "Docs", Icon: "/i.svg"}, Context: "A document"})
210 if strings.Contains(html, "<a estilo-bar-brand") || !strings.Contains(html, "<span estilo-bar-brand>") {
211 t.Fatal("public brand is a link", html)
212 }
213 if !strings.Contains(html, `<span estilo-bar-context dir="auto" title="A document">A document</span>`) {
214 t.Fatal("read-only context missing", html)
215 }
216}
217
218func TestSwitcherWithoutTilesIsAllAppsOnly(t *testing.T) {
219 b := signedIn("x")
220 html := render(t, "estilo-bar", b)
221 if strings.Contains(html, "estilo-switcher-grid") || strings.Contains(html, "estilo-switcher-app") {
222 t.Fatal("a switcher with no tiles renders a grid")
223 }
224 b.Switcher.Apps = []SwitcherApp{{Name: "Docs", Href: "https://dir.example/apps/docs", Icon: "/d.svg", Current: true}, {Name: "Sheets", Href: "https://dir.example/apps/sheets", Icon: "/s.svg"}}
225 b.Switcher.CurrentLabel = "(this app)"
226 html = render(t, "estilo-bar", b)
227 if !strings.Contains(html, `aria-current="true"`) || !strings.Contains(html, "(this app)") {
228 t.Fatal("the current tile is not marked in words", html)
229 }
230 if strings.Count(html, "(this app)") != 1 {
231 t.Fatal("more than one tile is current")
232 }
233}
234
235func TestNoMenuTriggerWithoutNavOrActions(t *testing.T) {
236 b := signedIn("x")
237 b.Nav, b.Actions = nil, nil
238 if html := render(t, "estilo-bar", b); strings.Contains(html, "estilo-bar-more") || strings.Contains(html, "estilo-bar-actions") {
239 t.Fatal("an empty Menu is rendered", html)
240 }
241}
242
243// An action is not always a link. Sheets' Create is a POST, and it must
244// be one in both places the bar draws it: a primary button at wide widths
245// and a row in the narrow Menu sheet. Drawn as a link in the sheet, it
246// would be a GET to the list and create nothing.
247func TestActionsCanPostInTheBarAndTheSheet(t *testing.T) {
248 html := render(t, "estilo-bar", signedIn("x"))
249 actions := html[strings.Index(html, "<div estilo-bar-actions>"):strings.Index(html, "<details estilo-bar-more")]
250 for _, want := range []string{
251 `<a rst-btn href="/d/abc/share">x-share</a>`,
252 `<form method="post" action="/d/new"><button rst-btn="primary" type="submit">x-create</button></form>`,
253 } {
254 if !strings.Contains(actions, want) {
255 t.Errorf("bar actions lack %q in %s", want, actions)
256 }
257 }
258 sheet := html[strings.Index(html, `<div estilo-menu="sheet">`):strings.Index(html, "<details estilo-switcher")]
259 for _, want := range []string{
260 `<a estilo-menu-item href="/d/abc/share"><span estilo-menu-label>x-share</span></a>`,
261 `<form method="post" action="/d/new"><button estilo-menu-item type="submit"><span estilo-menu-label>x-create</span></button></form>`,
262 } {
263 if !strings.Contains(sheet, want) {
264 t.Errorf("the Menu sheet lacks %q in %s", want, sheet)
265 }
266 }
267 b := signedIn("x")
268 b.Actions = []Action{{Item: menu.Item{Dialog: "help", Label: "x-help"}}}
269 html = render(t, "estilo-bar", b)
270 if !strings.Contains(html, `<button rst-btn type="button" commandfor="help" command="show-modal">x-help</button>`) {
271 t.Error("a dialog action is not an invoker button", html)
272 }
273}
274
275// A full-height app (Sheets' grid) keys its layout on the shell. The rule
276// is estilo's, not each app's: the working area is the viewport less the
277// bar, and the bar must neither grow nor shrink inside it.
278func TestTheFillShellGivesTheWorkingAreaTheRestOfTheViewport(t *testing.T) {
279 src := string(CSS())
280 for _, want := range []string{
281 "[estilo-shell~=\"fill\"] {\n display: flex;\n flex-direction: column;\n block-size: 100dvh;",
282 "[estilo-shell~=\"fill\"] > [estilo-bar] { flex: none; }",
283 "[estilo-shell~=\"fill\"] > main { flex: 1 1 auto; min-block-size: 0; }",
284 } {
285 if !strings.Contains(src, want) {
286 t.Errorf("chrome.css lacks the fill shell rule %q", want)
287 }
288 }
289}
290
291func TestUntrustedValuesAreEscaped(t *testing.T) {
292 b := signedIn("x")
293 b.ContextEdit.Value = `"><script>bad()</script>`
294 b.Account.Address = `<img src=x onerror=bad>`
295 b.Actions[0].Href = "javascript:bad()"
296 b.Actions[1].Action = "javascript:bad()"
297 html := render(t, "estilo-bar", b)
298 if strings.Contains(html, "<script>") || strings.Contains(html, "<img src=x") || !strings.Contains(html, "#ZgotmplZ") {
299 t.Fatal("unsafe bar markup", html)
300 }
301}
302
303func TestErrorPage(t *testing.T) {
304 for locale, label := range locales {
305 t.Run(locale, func(t *testing.T) {
306 html := render(t, "estilo-error", ErrorPage{Status: 500, Title: label + "-t", Body: label + "-b", Home: Link{Href: "/", Label: label + "-home"}, Back: &Link{Href: "/d/x", Label: label + "-back"}, Ref: "k3f9tq", RefLabel: label + "-ref"})
307 esc := template.HTMLEscapeString
308 for _, want := range []string{"<p rst-error-status>500</p>", esc(label) + "-t", esc(label) + "-b", `<a rst-btn="primary" href="/">` + esc(label) + "-home", `<a rst-btn href="/d/x">` + esc(label) + "-back", esc(label) + "-ref k3f9tq"} {
309 if !strings.Contains(html, want) {
310 t.Fatalf("missing %q in %s", want, html)
311 }
312 }
313 })
314 }
315 if html := render(t, "estilo-error", ErrorPage{Status: 404, Title: "t", Home: Link{Href: "/", Label: "h"}}); strings.Contains(html, "rst-error-ref") || strings.Contains(html, "rst-btn href") {
316 t.Fatal("a 404 with no ref and no checked referer shows either", html)
317 }
318}
319
320func TestSignInPages(t *testing.T) {
321 for locale, label := range locales {
322 t.Run(locale, func(t *testing.T) {
323 esc := template.HTMLEscapeString
324 html := render(t, "estilo-signin", SignIn{Icon: "/i.svg", Copy: &DoorCopy{Heading: label + "-h", AddressLabel: label + "-addr", Help: label + "-help", Submit: label + "-submit"}, Lede: label + "-lede", Error: label + "-err", Action: "/signin", Return: "/d/abc", Hidden: []Hidden{{"force", "1"}}, OpenSource: &Link{Href: "/credits", Label: label + "-os"}})
325 for _, want := range []string{"<h1>" + esc(label) + "-h</h1>", esc(label) + "-lede", `rst-tone="negative" role="alert"`, esc(label) + "-err", `action="/signin" novalidate`, `name="return_to" value="/d/abc"`, `name="force" value="1"`, `type="email"`, `autocomplete="email"`, esc(label) + "-addr", esc(label) + "-help", esc(label) + "-submit", esc(label) + "-os"} {
326 if !strings.Contains(html, want) {
327 t.Fatalf("missing %q in %s", want, html)
328 }
329 }
330 html = render(t, "estilo-signin-sent", SignInNotice{Icon: "/i.svg", Heading: label + "-sent", Body: label + "-body", Link: &Link{Href: "/signin", Label: label + "-again"}})
331 if !strings.Contains(html, esc(label)+"-sent") || !strings.Contains(html, `<a href="/signin">`+esc(label)+"-again") {
332 t.Fatal("sent page", html)
333 }
334 html = render(t, "estilo-signed-out", SignInNotice{Icon: "/i.svg", Heading: label + "-out", Body: label + "-body", Link: &Link{Href: "/signin", Label: label + "-in"}})
335 if !strings.Contains(html, `<a rst-btn="primary" href="/signin">`+esc(label)+"-in") {
336 t.Fatal("signed-out page", html)
337 }
338 })
339 }
340 // An address error belongs in the field, not a callout above it.
341 html := render(t, "estilo-signin", SignIn{Icon: "/i.svg", Action: "/signin", AddressError: "bad address"})
342 if strings.Contains(html, "rst-callout") || !strings.Contains(html, `rst-field-error id="estilo-signin-address-error" role="alert">bad address`) || !strings.Contains(html, `aria-invalid="true"`) {
343 t.Fatal("address error not in the field slot", html)
344 }
345}
346
347func TestInitials(t *testing.T) {
348 cases := []struct{ name, address, want string }{
349 {"Paul Campbell", "paul@tito.io", "P"},
350 {"", "paul@tito.io", "P"},
351 {"", " émile@example.test", "É"},
352 {" ", "", ""},
353 {"", "@example.test", ""},
354 {"", "._@example.test", ""},
355 {"李小龙", "", "李"},
356 }
357 for _, c := range cases {
358 if got := Initials(c.name, c.address); got != c.want {
359 t.Errorf("Initials(%q, %q) = %q, want %q", c.name, c.address, got, c.want)
360 }
361 }
362}
363
364func TestAppearanceAndThemeAttr(t *testing.T) {
365 for value, want := range map[string]Mode{"light": Light, "dark": Dark, "system": System, "": System, "auto": System, "Dark": System, "<x>": System} {
366 r := httptest.NewRequest("GET", "/", nil)
367 r.AddCookie(&http.Cookie{Name: AppearanceCookie, Value: value})
368 if got := Appearance(r); got != want {
369 t.Errorf("cookie %q: Appearance = %q, want %q", value, got, want)
370 }
371 }
372 if Appearance(httptest.NewRequest("GET", "/", nil)) != System {
373 t.Error("no cookie is not System")
374 }
375 for m, want := range map[Mode]string{Light: "light", Dark: "dark", System: "", Mode("x"): ""} {
376 if got := ThemeAttr(m); got != want {
377 t.Errorf("ThemeAttr(%q) = %q, want %q", m, got, want)
378 }
379 }
380}
381
382func post(form url.Values) *httptest.ResponseRecorder {
383 r := httptest.NewRequest("POST", "/appearance", strings.NewReader(form.Encode()))
384 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
385 w := httptest.NewRecorder()
386 HandleAppearance(w, r)
387 return w
388}
389
390func TestHandleAppearance(t *testing.T) {
391 w := post(url.Values{"mode": {"dark"}, "return_to": {"/d/abc?x=1"}})
392 if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/d/abc?x=1" {
393 t.Fatalf("got %d %q", w.Code, w.Header().Get("Location"))
394 }
395 c := w.Result().Cookies()
396 if len(c) != 1 || c[0].Name != AppearanceCookie || c[0].Value != "dark" || !c[0].Secure || !c[0].HttpOnly || c[0].SameSite != http.SameSiteLaxMode || c[0].Path != "/" || c[0].MaxAge < 364*24*3600 || c[0].Domain != "" {
397 t.Fatalf("cookie %+v", c)
398 }
399 // return_to that leaves the site falls back to the root; SafeReturn's
400 // control-character case is the one a prefix test misses.
401 for _, bad := range []string{"https://evil.example/", "//evil.example/", "/\\evil.example", "/\t/evil.example", "evil", ""} {
402 w := post(url.Values{"mode": {"light"}, "return_to": {bad}})
403 if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/" {
404 t.Errorf("return_to %q: got %d %q", bad, w.Code, w.Header().Get("Location"))
405 }
406 }
407 for _, bad := range []string{"auto", "", "DARK", "sepia"} {
408 if w := post(url.Values{"mode": {bad}, "return_to": {"/"}}); w.Code != http.StatusBadRequest || len(w.Result().Cookies()) != 0 {
409 t.Errorf("mode %q: got %d with cookies %v", bad, w.Code, w.Result().Cookies())
410 }
411 }
412 g := httptest.NewRecorder()
413 HandleAppearance(g, httptest.NewRequest("GET", "/appearance?mode=dark", nil))
414 if g.Code != http.StatusMethodNotAllowed || g.Header().Get("Allow") != "POST" || len(g.Result().Cookies()) != 0 {
415 t.Fatalf("GET: %d", g.Code)
416 }
417}
418
419// chrome.js must stay a classic, dependency-free script.
420func TestScriptIsClassicAndSelfContained(t *testing.T) {
421 src := string(JS())
422 if strings.Contains(src, "import ") || strings.Contains(src, "export ") {
423 t.Fatal("chrome.js must be a classic script")
424 }
425 // Escape and outside-click close every member of the one top-level
426 // group, the menubar's menus included: Sheets' menubar lost both when
427 // it left rastrillo's dropdown. And an invoker row closes the menu it
428 // was in, with a fallback where the platform lacks invoker commands.
429 for _, want := range []string{"Escape", "estilo-bar-title-input", "defaultValue", `details[name="estilo-menus"][open]`, "commandfor", "showModal"} {
430 if !strings.Contains(src, want) {
431 t.Errorf("chrome.js lost %q", want)
432 }
433 }
434 // Escape is heard in the capture phase: an app that stops a key from
435 // propagating out of its menus (Sheets, whose grid library listens on
436 // document) would otherwise swallow it before a bubble listener ran.
437 keydown := src[strings.Index(src, "addEventListener('keydown'"):]
438 keydown = keydown[:strings.Index(keydown[1:], "document.addEventListener(")+1]
439 if !strings.HasSuffix(strings.TrimSpace(keydown), "}, true)") {
440 t.Error("chrome.js's Escape handler is not in the capture phase")
441 }
442}
443
444// chrome.js's behaviour runs under Node against a minimal DOM
445// (chrome.test.mjs), the way savestatus's does.
446func TestScriptBehaviour(t *testing.T) {
447 out, err := exec.Command("node", "--test", "chrome.test.mjs").CombinedOutput()
448 if err != nil {
449 t.Fatalf("node --test chrome.test.mjs: %v\n%s", err, out)
450 }
451}
452
453// The door: the sign-in pages are one card on the suite's sky, headed by
454// the app's mark and name. Its sky markup must be exactly the sky
455// package's own, or the two would drift.
456func TestSignInIsTheOficinaDoor(t *testing.T) {
457 v := sky.For("calendar.oficina.works")
458 html := render(t, "estilo-signin", SignIn{Icon: "/i.svg", App: "Calendar", Sky: v, Action: "/signin"})
459 var want bytes.Buffer
460 if err := template.Must(template.ParseFS(sky.Templates(), "templates/*.html")).ExecuteTemplate(&want, "estilo-sky", v); err != nil {
461 t.Fatal(err)
462 }
463 if !strings.Contains(html, strings.TrimSpace(want.String())) {
464 t.Errorf("the door's sky differs from estilo-sky's:\n%s\nwant it to contain:\n%s", html, want.String())
465 }
466 for _, s := range []string{`<div estilo-door estilo-sky-host>`, `<section estilo-signin>`, `<span>Calendar</span>`, ` autofocus`, `</section>
467</div>`} {
468 if !strings.Contains(html, s) {
469 t.Errorf("door lacks %q", s)
470 }
471 }
472 for _, name := range []string{"estilo-signin-sent", "estilo-signed-out"} {
473 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") {
474 t.Errorf("%s is not drawn as the door", name)
475 }
476 }
477 // No sky given, no sky drawn: the card still stands.
478 if h := render(t, "estilo-signin", SignIn{Icon: "/i.svg", Action: "/s"}); strings.Contains(h, "estilo-sky-layer") {
479 t.Error("a sign-in with no Sky drew one")
480 }
481}
482
483// The card styles its own links: an app that styles no bare <a> would
484// otherwise show the browser's default blue on the door.
485func TestTheDoorStylesItsLinks(t *testing.T) {
486 src := string(CSS())
487 for _, want := range []string{":where([estilo-signin]) a", "[estilo-signin-foot] a"} {
488 if !strings.Contains(src, want) {
489 t.Errorf("chrome.css does not style %q", want)
490 }
491 }
492}
493
494// iOS zooms the page into any text control under 16px. rastrillo's
495// tokens.css brings controls up to 16px on a coarse pointer with a bare
496// `input` selector, and the bar's title field outweighs that with
497// `font: inherit` at 14px, so it zoomed on every tap in every app with a
498// title (found in Sheets, 2026-10-01: its TestNoZoomOnTap). chrome.css
499// restates rastrillo's rule for the field, at the field's own weight,
500// after the field's own rule.
501func TestTitleFieldDoesNotZoomOnTap(t *testing.T) {
502 src := string(CSS())
503 field := strings.Index(src, "[estilo-bar-title-input] {")
504 if field < 0 {
505 t.Fatal("chrome.css has no title field rule")
506 }
507 block := regexp.MustCompile(`@media \(pointer: coarse\) \{\s*\[estilo-bar-title-input\] \{\s*font-size: max\(1rem, 1em\);\s*\}\s*\}`)
508 at := block.FindStringIndex(src)
509 if at == nil {
510 t.Fatal("chrome.css does not size the title field to max(1rem, 1em) under (pointer: coarse)")
511 }
512 if at[0] < field {
513 t.Error("the coarse-pointer size comes before the title field's own rule, whose font: inherit then wins")
514 }
515}
516
517// The account at a sidebar's foot (Account.Rail), for an app whose chrome
518// is a rail, not the bar (Fichas, Paul 2026-10-01: "put any shared stuff in
519// the bottom left and maybe roll it into a dropup"). The same menu, the
520// same group and the same one Sign out; only where it sits changes.
521func TestTheAccountCanSitAtARailsFoot(t *testing.T) {
522 acc := signedIn("x").Account
523 bar := render(t, "estilo-account", acc)
524 if strings.Contains(bar, `estilo-account="rail"`) || strings.Contains(bar, "estilo-account-who") {
525 t.Error("the bar's account changed: Rail is off by default")
526 }
527 acc.Rail = true
528 html := render(t, "estilo-account", acc)
529 if !strings.Contains(html, `<details estilo-account="rail" name="estilo-menus">`) {
530 t.Errorf("a rail account is not marked, or left the one menu group:\n%s", html)
531 }
532 // In a rail the trigger is a row: who is signed in, beside the avatar.
533 summary := html[strings.Index(html, "<summary"):strings.Index(html, "</summary>")]
534 if !strings.Contains(summary, `<span estilo-account-who dir="auto">paul@example.test</span>`) {
535 t.Errorf("the rail trigger does not say who is signed in:\n%s", summary)
536 }
537 if n := strings.Count(html, "x-signout"); n != 1 {
538 t.Errorf("Sign out rendered %d times", n)
539 }
540
541 src := string(CSS())
542 // It opens upward where the rail is the viewport's height, and it is
543 // the foot's own width: hung from the inline end of a foot narrower
544 // than the bar's panel it ran off the window's start edge, and from the
545 // inline start it ran past the rail, which scrolls and so clips.
546 wide := regexp.MustCompile(`@media \(min-width: 800px\) \{\s*\[estilo-account~="rail"\] > \[estilo-account-panel\] \{[^}]*inset-block-start: auto;[^}]*inset-block-end: calc\(100% \+ 4px\);`)
547 if !wide.MatchString(src) {
548 t.Error("chrome.css does not open the rail account upward from 800px")
549 }
550 if !regexp.MustCompile(`\[estilo-account~="rail"\] > \[estilo-account-panel\] \{[^}]*inset-inline: 0;[^}]*inline-size: auto;[^}]*min-inline-size: 0;`).MatchString(src) {
551 t.Error("chrome.css does not size the rail account's panel to the rail's foot")
552 }
553 // The trigger is in the page flow: square, like every bar trigger.
554 if regexp.MustCompile(`\[estilo-account~="rail"\] > \[estilo-bar-trigger\] \{[^}]*border-radius: (?:[1-9]|var)`).MatchString(src) {
555 t.Error("the rail trigger is rounded; an in-flow highlight is square")
556 }
557}
558