package web import ( "regexp" "strings" "testing" ) // Dashboard layout fixes, 2026-10-08 (REPORT-dashboard-layout.md). A Go test cannot measure pixels; these pin the // markup and the rules the layout stands on, and the screenshots (felhom.eu/documentation/audits/ // dashboard-layout-2026-10-08/) show the consequence. // cssRule returns the body of the top-level rule whose selector line is exactly `selector {`. func cssRule(t *testing.T, css, selector string) string { t.Helper() i := strings.Index(css, "\n"+selector+" {") if i < 0 { t.Fatalf("style.css has no top-level %q rule", selector) } body := css[i+len(selector)+3:] return body[:strings.IndexByte(body, '}')] } // The Apps card header. Before: the title row and every tag were siblings in ONE space-between flex row; with two // or more tags (any installed app) the one-word name ran under them, and the logo was centred on the name block, // so a card with an address carried it lower than one without. // // RED-PROOF (REPORT): against 05e12921's stacks.html and style.css this fails — no .stack-tags, a tag before it, // no address title, and the header rule still `justify-content: space-between`. func TestStackCardHeaderLayout(t *testing.T) { s := i18nTestServer(t) var full i18nCase for _, c := range i18nCases() { if c.name == "stacks_full" { full = c } } if full.name == "" { t.Fatal("no stacks_full parity case") } addrRe := regexp.MustCompile(`\s*([^<]*) ↗\s*`) for _, lang := range []string{"hu", "en"} { page := renderI18nCase(t, s, lang, full) cards := strings.Split(page, `
`)[1:] if len(cards) != 8 { t.Fatalf("%s: %d card headers, want 8", lang, len(cards)) } addresses := 0 for i, c := range cards { head := c[:strings.Index(c, `
`)] title := strings.Index(head, `
`) tags := strings.Index(head, `
`) if title < 0 || tags < 0 || tags < title { t.Errorf("%s card %d: want the title row, then a .stack-tags row (title %d, tags %d)", lang, i, title, tags) continue } if first := strings.Index(head, `class="tag `); first < tags { t.Errorf("%s card %d: a tag outside the tags row (at %d, row at %d)", lang, i, first, tags) } if !strings.Contains(head[title:tags], `
`) { t.Errorf("%s card %d: the name block has no .stack-title-text (it carries min-width: 0)", lang, i) } if strings.Contains(head, "subdomain-link") { m := addrRe.FindStringSubmatch(head) if m == nil || m[1] != m[2] || m[2] != m[3] { t.Errorf("%s card %d: the address must be one ellipsis span with the full address in title: %q", lang, i, m) } addresses++ } } if addresses != 2 { t.Errorf("%s: %d addresses, want 2 (filebrowser, privatebin)", lang, addresses) } } data, err := StyleCSS() if err != nil { t.Fatal(err) } css := string(data) if h := cssRule(t, css, ".stack-detail-header"); strings.Contains(h, "space-between") || !strings.Contains(h, "flex-direction: column") { t.Errorf(".stack-detail-header must stack the title row over the tags, not spread them on one row: %s", h) } if r := cssRule(t, css, ".stack-title-row"); !strings.Contains(r, "align-items: flex-start") { t.Errorf(".stack-title-row must align the logo to the top of the name: %s", r) } if r := cssRule(t, css, ".stack-title-text"); !strings.Contains(r, "min-width: 0") { t.Errorf(".stack-title-text needs min-width: 0, or a long address cannot shrink: %s", r) } if r := cssRule(t, css, ".stack-tags"); !strings.Contains(r, "flex-wrap: wrap") { t.Errorf(".stack-tags must wrap (English tags are longer): %s", r) } if r := cssRule(t, css, ".subdomain-text"); !strings.Contains(r, "text-overflow: ellipsis") || !strings.Contains(r, "overflow: hidden") { t.Errorf(".subdomain-text must cut a long address with an ellipsis: %s", r) } if r := cssRule(t, css, ".subdomain-link"); !strings.Contains(r, "white-space: nowrap") { t.Errorf(".subdomain-link must keep the arrow on the address line: %s", r) } } // R-907 — on a phone the launcher's title, domain chip and share button sat on one row that does not wrap; the // button ran off the right edge (390 px, demo-hp 9201, 2026-10-08). // // RED-PROOF (REPORT): against 05e12921's style.css this fails — the 768px block has no .page-header rule, no // banner rule, and .alert-link has no colour. func TestR907_PageHeaderWrapsOnAPhone(t *testing.T) { data, err := StyleCSS() if err != nil { t.Fatal(err) } block := mediaBlock768(t, string(data)) if !regexp.MustCompile(`\.page-header \{[^}]*flex-wrap: wrap`).MatchString(block) { t.Error("the phone block must let .page-header wrap") } if !regexp.MustCompile(`\.share-open-btn \{[^}]*white-space: nowrap`).MatchString(block) { t.Error("the share button's text must stay whole when it drops to its own line") } // A banner on a phone: the long message breaks and the link wraps inside the banner (seen on 9202 once R-906 // let the real banners through: the link ran past the screen). And the link has a colour of its own. for _, re := range []string{`\.alert-banner \{[^}]*flex-wrap: wrap`, `\.alert-message \{[^}]*min-width: 0[^}]*overflow-wrap: anywhere`, `\.alert-link \{[^}]*flex-basis: 100%`} { if !regexp.MustCompile(re).MatchString(block) { t.Errorf("the phone block lacks %s", re) } } if r := cssRule(t, string(data), ".alert-link"); !strings.Contains(r, "color: inherit") { t.Errorf(".alert-link must take the banner's colour, not the browser's link blue: %s", r) } // The button is still in the header it wraps inside, in both languages. s := i18nTestServer(t) for _, c := range i18nCases() { if c.name != "launcher_full" { continue } for _, lang := range []string{"hu", "en"} { page := renderI18nCase(t, s, lang, c) i := strings.Index(page, `")] if !strings.Contains(head, "share-open-btn") { t.Errorf("%s: the share button left the launcher header", lang) } } return } t.Fatal("no launcher_full parity case") }