rastrillo / fiesta Public

Keep the landing page's theme bar one row on a phone, and hold the page to the Row Rule

At 390px the theme bar wrapped the colour-scheme menu onto a line of
its own under the theme buttons: the shape DESIGN.md's Row Rule
forbids, on the page that sells Fiesta. The rule's check only read the
gallery's examples, so nothing saw it.

The check moves from check's test file into internal/rowrule, so the
examples and the landing page are held by one function rather than
two copies that drift. A page that lays out its own bars names them by
class; the examples name none, so for them nothing changes. Whether a
row keeps its menu beside something is layout, so the landing test
adds what only a browser can say: at 320, 390 and 1280, in all eleven
languages the site builds (Arabic right to left), each menu shares a
line with something in its row, and the theme bar's label, buttons and
menu sit on one line.

Hiding the visible "Theme" label and closing the gaps was not enough:
three buttons and a worded menu need about 475px in Portuguese, and a
320px phone has under 300. So below 561px the scheme menu's summary
shows a moon in place of its words. The moon is the one the page
already draws beside "Light and dark, built in"; Fiesta's icons/ has
nothing for a colour scheme, and adding one would mean a new icon and
a new release. The label and the menu's words stay in the markup,
clipped from sight, so the group is still named by aria-labelledby and
the summary by scheme.label. The test holds both names at every width.
No new copy, and nothing under css/, js/, i18n/ or icons/ changes.

Red against dfd1f04's page: the scheme menu sat on a line of its own,
and the bar ran to two lines, at 320 in all eleven languages and at
390 in all but Bengali (21 of each). Named mutations, each red then
reverted: isRow ignoring the page's rows fails the new
TestAPageNamesItsOwnRows twice; dropping site-themebar from the
landing's rows fails the static check in every language; and the
scheme menu's words set to display: none instead of clipped leave the
summary unnamed at 320 and 390 in every language.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 0cadc7900a959fe85f6f81d1a86bf63d0c66cdc3 parent 640a5ad
7 files changed, +536 −253
  • check/rows_rule_test.go +6 −251
  • hack/test-weights.txt +1 −0
  • internal/rowrule/rowrule.go +195 −0
  • internal/rowrule/rowrule_test.go +123 −0
  • internal/www/landing_rows_browser_test.go +198 −0
  • site/landing/page.html.tmpl +1 −1
  • site/static/site.css +12 −1

CI failed — run details

CI log
=== gofmt ===
=== no-rastrillo ===
go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 .
ok  	amadan.net/rastrillo/fiesta	0.216s
=== size ===
go test ./internal/build -run '^TestTheOneLinkIsUnder25KiBGzipped$' -count=1 -v
=== RUN   TestTheOneLinkIsUnder25KiBGzipped
    build_test.go:138: fiesta-plain.css: 158347 bytes, 23104 bytes gzipped (22.6 KiB of the 25.0 KiB limit)
    build_test.go:138: fiesta-signal.css: 158442 bytes, 23217 bytes gzipped (22.7 KiB of the 25.0 KiB limit)
    build_test.go:138: fiesta.css: 161261 bytes, 23823 bytes gzipped (23.3 KiB of the 25.0 KiB limit)
--- PASS: TestTheOneLinkIsUnder25KiBGzipped (0.02s)
PASS
ok  	amadan.net/rastrillo/fiesta/internal/build	0.025s
=== history ===
history: 40 commits; release tags: v1.0.0
=== suite ===
--- shard 0 of 48 ---
==> shard 0 of 48: make root
go build ./...
go vet ./...
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 FIESTA_TEST_REQUIRE_TAGS=1 ./hack/gotest.sh root 'internal/gallery@3 cmd/fiesta@2 internal/www@2'
ok  	amadan.net/rastrillo/fiesta	4.086s
ok  	amadan.net/rastrillo/fiesta/check	15.198s
ok  	amadan.net/rastrillo/fiesta/example	0.330s
?   	amadan.net/rastrillo/fiesta/hack/seed/english	[no test files]
ok  	amadan.net/rastrillo/fiesta/internal/axe	0.354s
ok  	amadan.net/rastrillo/fiesta/internal/build	2.694s
ok  	amadan.net/rastrillo/fiesta/internal/codeview	0.161s
?   	amadan.net/rastrillo/fiesta/internal/harness	[no test files]
ok  	amadan.net/rastrillo/fiesta/internal/markup	0.156s
ok  	amadan.net/rastrillo/fiesta/internal/nodetest	0.322s
ok  	amadan.net/rastrillo/fiesta/internal/rowrule	0.155s
ok  	amadan.net/rastrillo/fiesta/internal/site	3.717s
ok  	amadan.net/rastrillo/fiesta/internal/tokens	0.009s
suite: root took 31s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m12.920000s 0m2.360000s
--- shard 1 of 48 ---
==> shard 1 of 48: make test.internal.gallery~1of3
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 FIESTA_TEST_REQUIRE_TAGS=1 ./hack/gotest.sh test 'internal.gallery~1of3'
gotest: slice 1 of 3 of ./internal/gallery/ runs 37 of 109 test tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	107.682s
suite: test.internal.gallery~1of3 took 114s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m50.670000s 0m1.350000s
--- shard 2 of 48 ---
==> shard 2 of 48: make test.internal.gallery~2of3
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 FIESTA_TEST_REQUIRE_TAGS=1 ./hack/gotest.sh test 'internal.gallery~2of3'
gotest: slice 2 of 3 of ./internal/gallery/ runs 36 of 109 test tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	109.582s
suite: test.internal.gallery~2of3 took 116s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m53.740000s 0m1.370000s
--- shard 3 of 48 ---
==> shard 3 of 48: make test.internal.gallery~3of3
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 FIESTA_TEST_REQUIRE_TAGS=1 ./hack/gotest.sh test 'internal.gallery~3of3'
gotest: slice 3 of 3 of ./internal/gallery/ runs 36 of 109 test tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	101.273s
suite: test.internal.gallery~3of3 took 107s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m48.190000s 0m1.250000s
--- shard 4 of 48 ---
==> shard 4 of 48: make test.cmd.fiesta~1of2
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 FIESTA_TEST_REQUIRE_TAGS=1 ./hack/gotest.sh test 'cmd.fiesta~1of2'
gotest: slice 1 of 2 of ./cmd/fiesta/ runs 7 of 12 test tests
ok  	amadan.net/rastrillo/fiesta/cmd/fiesta	83.799s
suite: test.cmd.fiesta~1of2 took 88s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m34.550000s 0m2.270000s
--- shard 5 of 48 ---
==> shard 5 of 48: make test.cmd.fiesta~2of2
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 FIESTA_TEST_REQUIRE_TAGS=1 ./hack/gotest.sh test 'cmd.fiesta~2of2'
gotest: slice 2 of 2 of ./cmd/fiesta/ runs 5 of 12 test tests
ok  	amadan.net/rastrillo/fiesta/cmd/fiesta	87.665s
suite: test.cmd.fiesta~2of2 took 92s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m37.660000s 0m2.560000s
--- shard 6 of 48 ---
==> shard 6 of 48: make test.internal.www~1of2
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 FIESTA_TEST_REQUIRE_TAGS=1 ./hack/gotest.sh test 'internal.www~1of2'
gotest: slice 1 of 2 of ./internal/www/ runs 11 of 25 test tests
ok  	amadan.net/rastrillo/fiesta/internal/www	102.051s
suite: test.internal.www~1of2 took 110s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m48.990000s 0m1.810000s
--- shard 7 of 48 ---
==> shard 7 of 48: make test.internal.www~2of2
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 FIESTA_TEST_REQUIRE_TAGS=1 ./hack/gotest.sh test 'internal.www~2of2'
gotest: slice 2 of 2 of ./internal/www/ runs 14 of 25 test tests
ok  	amadan.net/rastrillo/fiesta/internal/www	101.698s
suite: test.internal.www~2of2 took 111s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m47.120000s 0m1.790000s
--- shard 8 of 48 ---
==> shard 8 of 48: make staticcheck
go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 -tags browser ./...
suite: staticcheck took 23s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m16.550000s 0m1.150000s
--- shard 9 of 48 ---
==> shard 9 of 48: make govulncheck
go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./...
=== Symbol Results ===

Vulnerability #1: GO-2026-6860
    Avoid quadratic complexity when coalescing text nodes in html in
    golang.org/x/net
  More info: https://pkg.go.dev/vuln/GO-2026-6860
  Module: golang.org/x/net
    Found in: golang.org/x/net@v0.58.0
    Fixed in: golang.org/x/net@v0.62.0
    Example traces found:
      #1: internal/rowrule/rowrule.go:60:25: rowrule.Breaches calls html.Parse

Your code is affected by 1 vulnerability from 1 module.
This scan also found 1 vulnerability in packages you import and 5
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.
Use '-show verbose' for more details.
exit status 3
make[1]: *** [Makefile:175: govulncheck] Error 1
suite: govulncheck took 15s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m3.560000s 0m0.410000s
make: *** [Makefile:76: suite] Error 2

amadan: step suite shard 9 of 48 failed: exit status 2
--- shard 10 of 48 ---
==> shard 10 of 48: make gitleaks
go run github.com/zricethezav/gitleaks/v8@v8.30.1 git --no-banner --redact --log-opts='--full-history --diff-filter=tuxdb HEAD' .
10:10AM INF Unknown SCM platform. Use --platform to include links in findings. host=amadan.net
10:10AM INF 40 commits scanned.
10:10AM INF scanned ~9302093 bytes (9.30 MB) in 10.2s
10:10AM INF no leaks found
suite: gitleaks took 16s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m8.200000s 0m0.340000s
--- shard 11 of 48 ---
==> shard 11 of 48: make browser.internal.harness
./hack/gotest.sh browser 'internal.harness'
gotest: on ip-172-31-19-131, driving the harness's own choice (/usr/local/bin/chromium)
gotest: ./internal/harness/ runs its 10 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/harness	13.264s
suite: browser.internal.harness took 24s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m1.890000s 0m1.340000s
--- shard 12 of 48 ---
==> shard 12 of 48: make browser.check~1of13
./hack/gotest.sh browser 'check~1of13'
gotest: on ip-172-31-19-131, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 1 of 13 of ./check/ runs 17 of 228 browser tests
ok  	amadan.net/rastrillo/fiesta/check	144.625s
suite: browser.check~1of13 took 163s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m51.250000s 0m13.680000s
--- shard 13 of 48 ---
==> shard 13 of 48: make browser.check~2of13
./hack/gotest.sh browser 'check~2of13'
gotest: on ip-172-31-19-131, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 2 of 13 of ./check/ runs 17 of 228 browser tests
ok  	amadan.net/rastrillo/fiesta/check	155.192s
suite: browser.check~2of13 took 173s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m57.770000s 0m15.630000s
--- shard 14 of 48 ---
==> shard 14 of 48: make browser.check~3of13
./hack/gotest.sh browser 'check~3of13'
gotest: on ip-172-31-19-131, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 3 of 13 of ./check/ runs 16 of 228 browser tests
ok  	amadan.net/rastrillo/fiesta/check	137.965s
suite: browser.check~3of13 took 157s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m45.120000s 0m12.130000s
--- shard 15 of 48 ---
==> shard 15 of 48: make browser.check~4of13
./hack/gotest.sh browser 'check~4of13'
gotest: on ip-172-31-19-131, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 4 of 13 of ./check/ runs 16 of 228 browser tests
ok  	amadan.net/rastrillo/fiesta/check	146.852s
suite: browser.check~4of13 took 164s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m57.040000s 0m16.790000s
--- shard 16 of 48 ---
==> shard 16 of 48: make browser.check~5of13
./hack/gotest.sh browser 'check~5of13'
gotest: on ip-172-31-19-131, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 5 of 13 of ./check/ runs 18 of 228 browser tests
ok  	amadan.net/rastrillo/fiesta/check	130.961s
suite: browser.check~5of13 took 150s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m43.660000s 0m14.160000s
--- shard 17 of 48 ---
==> shard 17 of 48: make browser.check~6of13
./hack/gotest.sh browser 'check~6of13'
gotest: on ip-172-31-19-131, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 6 of 13 of ./check/ runs 18 of 228 browser tests
ok  	amadan.net/rastrillo/fiesta/check	139.458s
suite: browser.check~6of13 took 159s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m49.540000s 0m16.190000s
--- shard 18 of 48 ---
==> shard 18 of 48: make browser.check~7of13
./hack/gotest.sh browser 'check~7of13'
gotest: on ip-172-31-19-131, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 7 of 13 of ./check/ runs 18 of 228 browser tests
ok  	amadan.net/rastrillo/fiesta/check	138.911s
suite: browser.check~7of13 took 157s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m48.040000s 0m14.310000s
--- shard 19 of 48 ---
==> shard 19 of 48: make browser.check~8of13
./hack/gotest.sh browser 'check~8of13'
gotest: on ip-172-31-19-131, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 8 of 13 of ./check/ runs 18 of 228 browser tests
ok  	amadan.net/rastrillo/fiesta/check	145.406s
suite: browser.check~8of13 took 163s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m46.900000s 0m14.820000s
--- shard 20 of 48 ---
==> shard 20 of 48: make browser.check~9of13
./hack/gotest.sh browser 'check~9of13'
gotest: on ip-172-31-19-131, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 9 of 13 of ./check/ runs 18 of 228 browser tests
ok  	amadan.net/rastrillo/fiesta/check	119.812s
suite: browser.check~9of13 took 138s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m33.540000s 0m10.060000s
--- shard 21 of 48 ---
==> shard 21 of 48: make browser.check~10of13
./hack/gotest.sh browser 'check~10of13'
gotest: on ip-172-31-19-131, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 10 of 13 of ./check/ runs 18 of 228 browser tests
ok  	amadan.net/rastrillo/fiesta/check	132.889s
suite: browser.check~10of13 took 150s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m42.170000s 0m12.220000s
--- shard 22 of 48 ---
==> shard 22 of 48: make browser.check~11of13
./hack/gotest.sh browser 'check~11of13'
gotest: on ip-172-31-19-131, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 11 of 13 of ./check/ runs 18 of 228 browser tests
ok  	amadan.net/rastrillo/fiesta/check	131.774s
suite: browser.check~11of13 took 152s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m44.200000s 0m13.310000s
--- shard 23 of 48 ---
==> shard 23 of 48: make browser.check~12of13
./hack/gotest.sh browser 'check~12of13'
gotest: on ip-172-31-19-131, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 12 of 13 of ./check/ runs 18 of 228 browser tests
ok  	amadan.net/rastrillo/fiesta/check	137.935s
suite: browser.check~12of13 took 159s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m50.700000s 0m15.150000s
--- shard 24 of 48 ---
==> shard 24 of 48: make browser.check~13of13
./hack/gotest.sh browser 'check~13of13'
gotest: on ip-172-31-19-131, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 13 of 13 of ./check/ runs 18 of 228 browser tests
ok  	amadan.net/rastrillo/fiesta/check	131.589s
suite: browser.check~13of13 took 154s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m42.740000s 0m12.080000s
--- shard 25 of 48 ---
==> shard 25 of 48: make browser.internal.gallery~1of18
./hack/gotest.sh browser 'internal.gallery~1of18'
gotest: on ip-172-31-19-131, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 1 of 18 of ./internal/gallery/ runs 6 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	67.329s
suite: browser.internal.gallery~1of18 took 83s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m16.390000s 0m3.860000s
--- shard 26 of 48 ---
==> shard 26 of 48: make browser.internal.gallery~2of18
./hack/gotest.sh browser 'internal.gallery~2of18'
gotest: on ip-172-31-19-131, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 2 of 18 of ./internal/gallery/ runs 5 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	80.664s
suite: browser.internal.gallery~2of18 took 94s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m15.710000s 0m3.210000s
--- shard 27 of 48 ---
==> shard 27 of 48: make browser.internal.gallery~3of18
./hack/gotest.sh browser 'internal.gallery~3of18'
gotest: on ip-172-31-19-131, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 3 of 18 of ./internal/gallery/ runs 5 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	87.166s
suite: browser.internal.gallery~3of18 took 102s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m16.860000s 0m3.890000s
--- shard 28 of 48 ---
==> shard 28 of 48: make browser.internal.gallery~4of18
./hack/gotest.sh browser 'internal.gallery~4of18'
gotest: on ip-172-31-19-131, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 4 of 18 of ./internal/gallery/ runs 5 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	68.354s
suite: browser.internal.gallery~4of18 took 84s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m15.290000s 0m3.060000s
--- shard 29 of 48 ---
==> shard 29 of 48: make browser.internal.gallery~5of18
./hack/gotest.sh browser 'internal.gallery~5of18'
gotest: on ip-172-31-19-131, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 5 of 18 of ./internal/gallery/ runs 6 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	102.366s
suite: browser.internal.gallery~5of18 took 117s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m20.820000s 0m5.520000s
--- shard 30 of 48 ---
==> shard 30 of 48: make browser.internal.gallery~6of18
./hack/gotest.sh browser 'internal.gallery~6of18'
gotest: on ip-172-31-19-131, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 6 of 18 of ./internal/gallery/ runs 5 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	91.612s
suite: browser.internal.gallery~6of18 took 105s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m17.450000s 0m4.030000s
--- shard 31 of 48 ---
==> shard 31 of 48: make browser.internal.gallery~7of18
./hack/gotest.sh browser 'internal.gallery~7of18'
gotest: on ip-172-31-19-131, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 7 of 18 of ./internal/gallery/ runs 5 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	78.507s
suite: browser.internal.gallery~7of18 took 92s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m15.140000s 0m2.790000s
--- shard 32 of 48 ---
==> shard 32 of 48: make browser.internal.gallery~8of18
./hack/gotest.sh browser 'internal.gallery~8of18'
gotest: on ip-172-31-19-131, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 8 of 18 of ./internal/gallery/ runs 5 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	85.741s
suite: browser.internal.gallery~8of18 took 102s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m19.100000s 0m4.880000s
--- shard 33 of 48 ---
==> shard 33 of 48: make browser.internal.gallery~9of18
./hack/gotest.sh browser 'internal.gallery~9of18'
gotest: on ip-172-31-19-131, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 9 of 18 of ./internal/gallery/ runs 6 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	111.308s
suite: browser.internal.gallery~9of18 took 127s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m25.420000s 0m7.760000s
--- shard 34 of 48 ---
==> shard 34 of 48: make browser.internal.gallery~10of18
./hack/gotest.sh browser 'internal.gallery~10of18'
gotest: on ip-172-31-19-131, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 10 of 18 of ./internal/gallery/ runs 2 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	83.945s
suite: browser.internal.gallery~10of18 took 99s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m15.110000s 0m2.930000s
--- shard 35 of 48 ---
==> shard 35 of 48: make browser.internal.gallery~11of18
./hack/gotest.sh browser 'internal.gallery~11of18'
gotest: on ip-172-31-19-131, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 11 of 18 of ./internal/gallery/ runs 2 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	89.130s
suite: browser.internal.gallery~11of18 took 102s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m15.960000s 0m3.020000s
--- shard 36 of 48 ---
==> shard 36 of 48: make browser.internal.gallery~12of18
./hack/gotest.sh browser 'internal.gallery~12of18'
gotest: on ip-172-31-19-131, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 12 of 18 of ./internal/gallery/ runs 2 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	105.198s
suite: browser.internal.gallery~12of18 took 121s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m20.480000s 0m5.260000s
--- shard 37 of 48 ---
==> shard 37 of 48: make browser.internal.gallery~13of18
./hack/gotest.sh browser 'internal.gallery~13of18'
gotest: on ip-172-31-19-131, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 13 of 18 of ./internal/gallery/ runs 4 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	105.454s
suite: browser.internal.gallery~13of18 took 120s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m20.740000s 0m5.600000s
--- shard 38 of 48 ---
==> shard 38 of 48: make browser.internal.gallery~14of18
./hack/gotest.sh browser 'internal.gallery~14of18'
gotest: on ip-172-31-19-131, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 14 of 18 of ./internal/gallery/ runs 5 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	112.084s
suite: browser.internal.gallery~14of18 took 127s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m20.840000s 0m5.520000s
--- shard 39 of 48 ---
==> shard 39 of 48: make browser.internal.gallery~15of18
./hack/gotest.sh browser 'internal.gallery~15of18'
gotest: on ip-172-31-19-131, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 15 of 18 of ./internal/gallery/ runs 5 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	109.365s
suite: browser.internal.gallery~15of18 took 124s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m21.280000s 0m5.830000s
--- shard 40 of 48 ---
==> shard 40 of 48: make browser.internal.gallery~16of18
./hack/gotest.sh browser 'internal.gallery~16of18'
gotest: on ip-172-31-19-131, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 16 of 18 of ./internal/gallery/ runs 5 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	112.238s
suite: browser.internal.gallery~16of18 took 126s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m18.160000s 0m4.660000s
--- shard 41 of 48 ---
==> shard 41 of 48: make browser.internal.gallery~17of18
./hack/gotest.sh browser 'internal.gallery~17of18'
gotest: on ip-172-31-19-131, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 17 of 18 of ./internal/gallery/ runs 5 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	115.901s
suite: browser.internal.gallery~17of18 took 130s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m20.750000s 0m5.700000s
--- shard 42 of 48 ---
==> shard 42 of 48: make browser.internal.gallery~18of18
./hack/gotest.sh browser 'internal.gallery~18of18'
gotest: on ip-172-31-19-131, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 18 of 18 of ./internal/gallery/ runs 6 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	121.132s
suite: browser.internal.gallery~18of18 took 136s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m22.750000s 0m6.590000s
--- shard 43 of 48 ---
==> shard 43 of 48: make browser.internal.gallery.history
./hack/gotest.sh browser 'internal.gallery.history'
gotest: on ip-172-31-19-131, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: ./internal/gallery/history/ runs its 3 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery/history	59.558s
suite: browser.internal.gallery.history took 69s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m22.690000s 0m2.250000s
--- shard 44 of 48 ---
==> shard 44 of 48: make browser.internal.www~1of4
./hack/gotest.sh browser 'internal.www~1of4'
gotest: on ip-172-31-19-131, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 1 of 4 of ./internal/www/ runs 5 of 24 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/www	115.523s
suite: browser.internal.www~1of4 took 129s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m41.010000s 0m2.400000s
--- shard 45 of 48 ---
==> shard 45 of 48: make browser.internal.www~2of4
./hack/gotest.sh browser 'internal.www~2of4'
gotest: on ip-172-31-19-131, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 2 of 4 of ./internal/www/ runs 7 of 24 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/www	132.708s
suite: browser.internal.www~2of4 took 146s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m56.570000s 0m3.420000s
--- shard 46 of 48 ---
==> shard 46 of 48: make browser.internal.www~3of4
./hack/gotest.sh browser 'internal.www~3of4'
gotest: on ip-172-31-19-131, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 3 of 4 of ./internal/www/ runs 6 of 24 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/www	129.930s
suite: browser.internal.www~3of4 took 143s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m55.480000s 0m3.380000s
--- shard 47 of 48 ---
==> shard 47 of 48: make browser.internal.www~4of4
./hack/gotest.sh browser 'internal.www~4of4'
gotest: on ip-172-31-19-131, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 4 of 4 of ./internal/www/ runs 6 of 24 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/www	124.935s
suite: browser.internal.www~4of4 took 141s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m49.100000s 0m3.370000s

amadan: job failed at step suite shard 9 of 48 — its output is above, not at the tail of this log
diff --git a/check/rows_rule_test.go b/check/rows_rule_test.go
index 5a9d3b0..b814068 100644
--- a/check/rows_rule_test.go
+++ b/check/rows_rule_test.go
@@ -1,266 +1,21 @@
package check
import (
- "regexp"
"slices"
"strings"
"testing"
- xhtml "golang.org/x/net/html"
-
"amadan.net/rastrillo/fiesta"
- "amadan.net/rastrillo/fiesta/internal/markup"
+ "amadan.net/rastrillo/fiesta/internal/rowrule"
)
// The Row Rule: a dropdown never sits on a line of its own. It sits in
// a row beside what it acts on. A menu alone on a line reads as a
// control for nothing in particular, and one above a page's title
// pushes the title down and reads as the page's first action. DESIGN.md
-// states the rule; these tests hold every example to it, so a gallery
-// page can never teach the placement the rule forbids.
-
-// dropdownRows are the rows a dropdown may sit in, each with why it is
-// a row: a dropdown's nearest ancestor that lays something out must
-// carry one of these.
-var dropdownRows = map[string]string{
- "rst-page-header": "the page header: a title and its actions",
- "rst-box-head": "a section head: a heading and its actions",
- "rst-lbar": "a list's toolbar",
- "rst-bulkbar": "the bulk bar",
- "rst-shell-tail": "the shell's bar",
- "rst-shell-rail-foot": "the rail's foot",
- "rst-page-foot": "the column's footer row",
- "rst-dropdown-menu": "a dropdown inside a menu is one of that menu's rows",
-}
-
-// shellChrome is the shell's own chrome. A dropdown there is laid out
-// beside the page, not above its title, so it may come before the page
-// header in document order; anything else that does is above the title.
-var shellChrome = []string{"rst-shell-tail", "rst-shell-rail-foot"}
-
-// dropdownsOutOfRow reads one piece of markup and returns one message
-// per breach of the Row Rule, in document order. It is a pure function
-// so its own test can hold each rule to an exact message; the walk over
-// every example is TestEveryDropdownSitsInARow.
-//
-// The markup is respelled first, so a class-spelled dropdown is visited
-// and a class-spelled row counts as its attribute twin. The respeller's
-// notes are not read: most are about escaped markup in a code sample,
-// which is text and holds no dropdown. What matters is an element whose
-// class list it left alone, and that is found on the parsed element.
-func dropdownsOutOfRow(src string) []string {
- respelled, _ := markup.Respell([]byte(src))
- var out []string
- doc, err := xhtml.Parse(strings.NewReader(string(respelled)))
- if err != nil {
- return append(out, "the markup does not parse: "+err.Error())
- }
- var order []*xhtml.Node
- var walk func(*xhtml.Node)
- walk = func(n *xhtml.Node) {
- if n.Type == xhtml.ElementNode {
- order = append(order, n)
- }
- for c := n.FirstChild; c != nil; c = c.NextSibling {
- walk(c)
- }
- }
- walk(doc)
- header := slices.IndexFunc(order, func(n *xhtml.Node) bool { return nodeCarries(n, "rst-page-header") })
- for at, n := range order {
- if c, ok := unrespelled(n); ok {
- out = append(out, `a class list could not be respelled, so the check cannot read it: <`+n.Data+` class="`+c+`">`)
- }
- if !isDropdown(n) {
- continue
- }
- row := n.Parent
- for row != nil && groupsControls(row) {
- row = row.Parent
- }
- if row == nil || !isRow(row) {
- out = append(out, "a dropdown sits in "+describe(row)+", not in a row")
- }
- // Every header and section head around the menu needs its
- // heading, not only the nearest row: a toolbar inside a titleless
- // page header is still a menu with nothing beside it to act on.
- for p := n.Parent; p != nil; p = p.Parent {
- if nodeCarries(p, "rst-page-header") && !holds(p, "h1") {
- out = append(out, "a page header holds a dropdown and no h1")
- }
- if nodeCarries(p, "rst-box-head") && !holds(p, "h2", "h3", "h4", "h5", "h6") {
- out = append(out, "a section head holds a dropdown and no heading")
- }
- }
- if header >= 0 && at < header && !inShellChrome(n) {
- out = append(out, "a dropdown comes before the page header")
- }
- }
- return out
-}
-
-// unrespelled returns the class list of an element the respeller left
-// as it was while it still names a component class: a dropdown or a row
-// written that way is one this check would never see. Utility classes
-// such as rst-sr-only have no attribute twin and stay classes by design.
-func unrespelled(n *xhtml.Node) (string, bool) {
- for _, a := range n.Attr {
- if a.Key != "class" {
- continue
- }
- for _, name := range rstClassName.FindAllString(a.Val, -1) {
- if _, _, ok := markup.Attribute(name); ok {
- return a.Val, true
- }
- }
- }
- return "", false
-}
-
-var rstClassName = regexp.MustCompile(`rst-[A-Za-z0-9_-]+`)
-
-func isDropdown(n *xhtml.Node) bool { return n.Data == "details" && nodeCarries(n, "rst-dropdown") }
-
-// nodeCarries reports whether n has the attribute name, whatever its value.
-func nodeCarries(n *xhtml.Node, name string) bool {
- return slices.ContainsFunc(n.Attr, func(a xhtml.Attribute) bool { return a.Key == name })
-}
-
-// groupsControls is a <form> or <search> with no rst- attribute: it
-// groups a row's controls and lays nothing out, so the row is above it.
-// One that carries an rst- attribute is styled, and is what the menu
-// sits in.
-func groupsControls(n *xhtml.Node) bool {
- if n.Type != xhtml.ElementNode || (n.Data != "form" && n.Data != "search") {
- return false
- }
- return !slices.ContainsFunc(n.Attr, func(a xhtml.Attribute) bool { return strings.HasPrefix(a.Key, "rst-") })
-}
-
-func isRow(n *xhtml.Node) bool {
- return slices.ContainsFunc(n.Attr, func(a xhtml.Attribute) bool { _, ok := dropdownRows[a.Key]; return ok })
-}
-
-// describe names an element as a failure shows it: its tag and its rst-
-// attributes, main[rst-page], so the reader can find it in the markup.
-func describe(n *xhtml.Node) string {
- if n == nil || n.Type != xhtml.ElementNode {
- return "the document"
- }
- s := n.Data
- for _, a := range n.Attr {
- if strings.HasPrefix(a.Key, "rst-") {
- s += "[" + a.Key + "]"
- }
- }
- return s
-}
-
-// holds reports whether row has one of the tags among its descendants,
-// outside any dropdown: a heading inside a menu's panel titles nothing
-// in the row, so a header holding only that is still a menu on its own.
-func holds(row *xhtml.Node, tags ...string) bool {
- for c := row.FirstChild; c != nil; c = c.NextSibling {
- if c.Type != xhtml.ElementNode || isDropdown(c) {
- continue
- }
- if slices.Contains(tags, c.Data) || holds(c, tags...) {
- return true
- }
- }
- return false
-}
-
-func inShellChrome(n *xhtml.Node) bool {
- for p := n.Parent; p != nil; p = p.Parent {
- if slices.ContainsFunc(shellChrome, func(name string) bool { return nodeCarries(p, name) }) {
- return true
- }
- }
- return false
-}
-
-// TestTheRowCheckNamesEachBreach: each way a dropdown can break the
-// rule is named by its own message, in both spellings, and each shape
-// the rule allows passes. A checker that only counted breaches would
-// pass a page that broke the rule one way and was fixed another.
-func TestTheRowCheckNamesEachBreach(t *testing.T) {
- const header = `<header rst-page-header><h1>T</h1></header>`
- cases := []struct {
- name, markup string
- want []string
- }{
- {"alone at the top of the page, above its title",
- `<main rst-page><details rst-dropdown></details>` + header + `</main>`,
- []string{"a dropdown sits in main[rst-page], not in a row", "a dropdown comes before the page header"}},
- {"in a bare div",
- `<div><details rst-dropdown></details></div>`,
- []string{"a dropdown sits in div, not in a row"}},
- {"in a toolbar above the page's title",
- `<main rst-page><div rst-lbar><details rst-dropdown></details></div>` + header + `</main>`,
- []string{"a dropdown comes before the page header"}},
- {"a page header with no title",
- `<header rst-page-header><details rst-dropdown></details></header>`,
- []string{"a page header holds a dropdown and no h1"}},
- {"a section head with no heading",
- `<div rst-box-head><details rst-dropdown></details></div>`,
- []string{"a section head holds a dropdown and no heading"}},
- {"class-spelled, alone in the body",
- `<body><details class="rst-dropdown"></details></body>`,
- []string{"a dropdown sits in body, not in a row"}},
- {"in a form that lays itself out",
- `<div rst-lbar><form rst-search><details rst-dropdown></details></form></div>`,
- []string{"a dropdown sits in form[rst-search], not in a row"}},
- {"a heading inside the menu is not the section's",
- `<div rst-box-head><details rst-dropdown><summary>S</summary><div rst-dropdown-menu><h2>P</h2></div></details></div>`,
- []string{"a section head holds a dropdown and no heading"}},
-
- {"in a toolbar inside a page header with no title",
- `<header rst-page-header><div rst-lbar><details rst-dropdown><summary>Menu</summary></details></div></header>`,
- []string{"a page header holds a dropdown and no h1"}},
- {"class-spelled, in a toolbar inside a page header with no title",
- `<header class="rst-page-header"><div class="rst-lbar"><details class="rst-dropdown"><summary>Menu</summary></details></div></header>`,
- []string{"a page header holds a dropdown and no h1"}},
- {"in a toolbar inside a section head with no heading",
- `<div rst-box-head><div rst-lbar><details rst-dropdown><summary>Menu</summary></details></div></div>`,
- []string{"a section head holds a dropdown and no heading"}},
- {"class-spelled, in a toolbar inside a section head with no heading",
- `<div class="rst-box-head"><div class="rst-lbar"><details class="rst-dropdown"><summary>Menu</summary></details></div></div>`,
- []string{"a section head holds a dropdown and no heading"}},
- {"a class list the respeller cannot read",
- `<main rst-page><details class=rst-dropdown{{.Open}}></details></main>`,
- []string{`a class list could not be respelled, so the check cannot read it: <details class="rst-dropdown{{.Open}}">`}},
-
- {"escaped markup in a code sample",
- `<p><code>class=&quot;rst-box&quot;</code></p>`, nil},
- {"in a toolbar inside a page header with its title",
- `<header rst-page-header><h1>T</h1><div rst-lbar><details rst-dropdown><summary>Menu</summary></details></div></header>`, nil},
- {"class-spelled, in a toolbar inside a section head with its heading",
- `<div class="rst-box-head"><h2>P</h2><div class="rst-lbar"><details class="rst-dropdown"><summary>Menu</summary></details></div></div>`, nil},
- {"class-spelled, in a section head with its heading",
- `<div class="rst-box-head"><h2>P</h2><details rst-dropdown></details></div>`, nil},
- {"in a toolbar, inside a form, after the toolbar's search",
- `<div rst-lbar><search><form rst-search method="get" action="/posts"><input type="search" name="q"></form></search>` +
- `<form method="get" action="/posts"><details rst-dropdown><summary>S</summary></details></form></div>`, nil},
- {"in a toolbar, inside a search's form",
- `<div rst-lbar><search><form method="get"><details rst-dropdown></details></form></search></div>`, nil},
- {"in the shell's bar, before the page header",
- `<div rst-shell-tail><details rst-dropdown></details></div><main rst-page>` + header + `</main>`, nil},
- {"in a menu in the rail's foot, before the page header",
- `<div rst-shell-rail-foot><details rst-dropdown><summary>S</summary><div rst-dropdown-menu><details rst-dropdown></details></div></details></div><main rst-page>` + header + `</main>`, nil},
- {"inside another dropdown's menu",
- `<div rst-box-head><h2>P</h2><details rst-dropdown><summary>S</summary><div rst-dropdown-menu><details rst-dropdown></details></div></details></div>`, nil},
- {"in the page header beside its title",
- `<main rst-page><header rst-page-header><div><h1>T</h1></div><details rst-dropdown></details></header></main>`, nil},
- {"in the footer row, after the page header",
- `<main rst-page>` + header + `<footer rst-page-foot><details rst-dropdown></details></footer></main>`, nil},
- }
- for _, c := range cases {
- if got := dropdownsOutOfRow(c.markup); !slices.Equal(got, c.want) {
- t.Errorf("%s:\n got %q\n want %q", c.name, got, c.want)
- }
- }
-}
+// states the rule, and internal/rowrule is its check, shared with the
+// site's landing page. This test holds every example to it, so a
+// gallery page can never teach the placement the rule forbids.
// TestEveryDropdownSitsInARow: every state of every example, bodies and
// layouts whole, keeps the Row Rule. The gallery is where a reader
@@ -273,7 +28,7 @@ func TestEveryDropdownSitsInARow(t *testing.T) {
if strings.Contains(s.Markup, "rst-dropdown") {
menus++
}
- for _, msg := range dropdownsOutOfRow(s.Markup) {
+ for _, msg := range rowrule.Breaches(s.Markup, nil) {
t.Errorf("%s/%s: %s", s.Name, s.State, msg)
}
}
@@ -283,7 +38,7 @@ func TestEveryDropdownSitsInARow(t *testing.T) {
}
if t.Failed() {
var rows []string
- for row, why := range dropdownRows {
+ for row, why := range rowrule.Rows {
rows = append(rows, row+" ("+why+")")
}
slices.Sort(rows)
diff --git a/hack/test-weights.txt b/hack/test-weights.txt
index 059b1fc..0570ce8 100644
--- a/hack/test-weights.txt
+++ b/hack/test-weights.txt
@@ -196,6 +196,7 @@ browser internal/www TestTheLanguageMenuOpensOnAPhone 14
browser internal/www TestTheLandingHasNoHorizontalScrollAt320 13
browser internal/www TestTheSampleFormsStayOnThePage 10
browser internal/www TestTheBuntingNeverLeavesItsRow 9
+browser internal/www TestTheLandingKeepsTheRowRule 9
browser internal/www TestTheBuntingHangsTheSameAfterAShake 9
browser internal/www TestArabicReadsRightToLeft 9
browser internal/www TestCopy 9
diff --git a/internal/rowrule/rowrule.go b/internal/rowrule/rowrule.go
new file mode 100644
index 0000000..c9df061
--- /dev/null
+++ b/internal/rowrule/rowrule.go
@@ -0,0 +1,195 @@
+// Package rowrule is the Row Rule as a check on markup. A dropdown
+// never sits on a line of its own. It sits in a row beside what it acts
+// on. A menu alone on a line reads as a control for nothing in
+// particular, and one above a page's title pushes the title down and
+// reads as the page's first action. DESIGN.md states the rule. The
+// check package holds every gallery example to it; the site's tests
+// hold the landing page to it in a browser, at a phone's widths too.
+// One check serves both, so the page that sells Fiesta cannot keep a
+// looser rule than the gallery teaches.
+package rowrule
+
+import (
+ "regexp"
+ "slices"
+ "strings"
+
+ xhtml "golang.org/x/net/html"
+
+ "amadan.net/rastrillo/fiesta/internal/markup"
+)
+
+// Rows are Fiesta's rows a dropdown may sit in, each with why it is a
+// row: a dropdown's nearest ancestor that lays something out must carry
+// one of these, or be one of the page's own rows that Breaches is given.
+var Rows = map[string]string{
+ "rst-page-header": "the page header: a title and its actions",
+ "rst-box-head": "a section head: a heading and its actions",
+ "rst-lbar": "a list's toolbar",
+ "rst-bulkbar": "the bulk bar",
+ "rst-shell-tail": "the shell's bar",
+ "rst-shell-rail-foot": "the rail's foot",
+ "rst-page-foot": "the column's footer row",
+ "rst-dropdown-menu": "a dropdown inside a menu is one of that menu's rows",
+}
+
+// shellChrome is the shell's own chrome. A dropdown there is laid out
+// beside the page, not above its title, so it may come before the page
+// header in document order; anything else that does is above the title.
+var shellChrome = []string{"rst-shell-tail", "rst-shell-rail-foot"}
+
+// Breaches reads one piece of markup and returns one message per
+// breach of the Row Rule, in document order. It is a pure function so
+// its own test can hold each rule to an exact message.
+//
+// pageRows names the page's own rows by class, each with why it is a
+// row, for a page that lays out its own bars, as the site's landing
+// page does. A gallery example passes none: there, a row is a Fiesta
+// row. Naming a row here says only where the menu sits; that the row
+// keeps the menu on a line with something else is a question of layout,
+// which a browser has to answer at each width.
+//
+// The markup is respelled first, so a class-spelled dropdown is visited
+// and a class-spelled row counts as its attribute twin. The respeller's
+// notes are not read: most are about escaped markup in a code sample,
+// which is text and holds no dropdown. What matters is an element whose
+// class list it left alone, and that is found on the parsed element.
+func Breaches(src string, pageRows map[string]string) []string {
+ respelled, _ := markup.Respell([]byte(src))
+ var out []string
+ doc, err := xhtml.Parse(strings.NewReader(string(respelled)))
+ if err != nil {
+ return append(out, "the markup does not parse: "+err.Error())
+ }
+ var order []*xhtml.Node
+ var walk func(*xhtml.Node)
+ walk = func(n *xhtml.Node) {
+ if n.Type == xhtml.ElementNode {
+ order = append(order, n)
+ }
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ walk(c)
+ }
+ }
+ walk(doc)
+ header := slices.IndexFunc(order, func(n *xhtml.Node) bool { return nodeCarries(n, "rst-page-header") })
+ for at, n := range order {
+ if c, ok := unrespelled(n); ok {
+ out = append(out, `a class list could not be respelled, so the check cannot read it: <`+n.Data+` class="`+c+`">`)
+ }
+ if !isDropdown(n) {
+ continue
+ }
+ row := n.Parent
+ for row != nil && groupsControls(row) {
+ row = row.Parent
+ }
+ if row == nil || !isRow(row, pageRows) {
+ out = append(out, "a dropdown sits in "+describe(row)+", not in a row")
+ }
+ // Every header and section head around the menu needs its
+ // heading, not only the nearest row: a toolbar inside a titleless
+ // page header is still a menu with nothing beside it to act on.
+ for p := n.Parent; p != nil; p = p.Parent {
+ if nodeCarries(p, "rst-page-header") && !holds(p, "h1") {
+ out = append(out, "a page header holds a dropdown and no h1")
+ }
+ if nodeCarries(p, "rst-box-head") && !holds(p, "h2", "h3", "h4", "h5", "h6") {
+ out = append(out, "a section head holds a dropdown and no heading")
+ }
+ }
+ if header >= 0 && at < header && !inShellChrome(n) {
+ out = append(out, "a dropdown comes before the page header")
+ }
+ }
+ return out
+}
+
+// unrespelled returns the class list of an element the respeller left
+// as it was while it still names a component class: a dropdown or a row
+// written that way is one this check would never see. Utility classes
+// such as rst-sr-only have no attribute twin and stay classes by design.
+func unrespelled(n *xhtml.Node) (string, bool) {
+ for _, a := range n.Attr {
+ if a.Key != "class" {
+ continue
+ }
+ for _, name := range rstClassName.FindAllString(a.Val, -1) {
+ if _, _, ok := markup.Attribute(name); ok {
+ return a.Val, true
+ }
+ }
+ }
+ return "", false
+}
+
+var rstClassName = regexp.MustCompile(`rst-[A-Za-z0-9_-]+`)
+
+func isDropdown(n *xhtml.Node) bool { return n.Data == "details" && nodeCarries(n, "rst-dropdown") }
+
+// nodeCarries reports whether n has the attribute name, whatever its value.
+func nodeCarries(n *xhtml.Node, name string) bool {
+ return slices.ContainsFunc(n.Attr, func(a xhtml.Attribute) bool { return a.Key == name })
+}
+
+// groupsControls is a <form> or <search> with no rst- attribute: it
+// groups a row's controls and lays nothing out, so the row is above it.
+// One that carries an rst- attribute is styled, and is what the menu
+// sits in.
+func groupsControls(n *xhtml.Node) bool {
+ if n.Type != xhtml.ElementNode || (n.Data != "form" && n.Data != "search") {
+ return false
+ }
+ return !slices.ContainsFunc(n.Attr, func(a xhtml.Attribute) bool { return strings.HasPrefix(a.Key, "rst-") })
+}
+
+func isRow(n *xhtml.Node, pageRows map[string]string) bool {
+ return slices.ContainsFunc(n.Attr, func(a xhtml.Attribute) bool {
+ if _, ok := Rows[a.Key]; ok {
+ return true
+ }
+ if a.Key != "class" {
+ return false
+ }
+ return slices.ContainsFunc(strings.Fields(a.Val), func(c string) bool { _, ok := pageRows[c]; return ok })
+ })
+}
+
+// describe names an element as a failure shows it: its tag and its rst-
+// attributes, main[rst-page], so the reader can find it in the markup.
+func describe(n *xhtml.Node) string {
+ if n == nil || n.Type != xhtml.ElementNode {
+ return "the document"
+ }
+ s := n.Data
+ for _, a := range n.Attr {
+ if strings.HasPrefix(a.Key, "rst-") {
+ s += "[" + a.Key + "]"
+ }
+ }
+ return s
+}
+
+// holds reports whether row has one of the tags among its descendants,
+// outside any dropdown: a heading inside a menu's panel titles nothing
+// in the row, so a header holding only that is still a menu on its own.
+func holds(row *xhtml.Node, tags ...string) bool {
+ for c := row.FirstChild; c != nil; c = c.NextSibling {
+ if c.Type != xhtml.ElementNode || isDropdown(c) {
+ continue
+ }
+ if slices.Contains(tags, c.Data) || holds(c, tags...) {
+ return true
+ }
+ }
+ return false
+}
+
+func inShellChrome(n *xhtml.Node) bool {
+ for p := n.Parent; p != nil; p = p.Parent {
+ if slices.ContainsFunc(shellChrome, func(name string) bool { return nodeCarries(p, name) }) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/rowrule/rowrule_test.go b/internal/rowrule/rowrule_test.go
new file mode 100644
index 0000000..95c7b6c
--- /dev/null
+++ b/internal/rowrule/rowrule_test.go
@@ -0,0 +1,123 @@
+package rowrule
+
+import (
+ "slices"
+ "testing"
+)
+
+// TestTheRowCheckNamesEachBreach: each way a dropdown can break the
+// rule is named by its own message, in both spellings, and each shape
+// the rule allows passes. A checker that only counted breaches would
+// pass a page that broke the rule one way and was fixed another.
+func TestTheRowCheckNamesEachBreach(t *testing.T) {
+ const header = `<header rst-page-header><h1>T</h1></header>`
+ cases := []struct {
+ name, markup string
+ want []string
+ }{
+ {"alone at the top of the page, above its title",
+ `<main rst-page><details rst-dropdown></details>` + header + `</main>`,
+ []string{"a dropdown sits in main[rst-page], not in a row", "a dropdown comes before the page header"}},
+ {"in a bare div",
+ `<div><details rst-dropdown></details></div>`,
+ []string{"a dropdown sits in div, not in a row"}},
+ {"in a toolbar above the page's title",
+ `<main rst-page><div rst-lbar><details rst-dropdown></details></div>` + header + `</main>`,
+ []string{"a dropdown comes before the page header"}},
+ {"a page header with no title",
+ `<header rst-page-header><details rst-dropdown></details></header>`,
+ []string{"a page header holds a dropdown and no h1"}},
+ {"a section head with no heading",
+ `<div rst-box-head><details rst-dropdown></details></div>`,
+ []string{"a section head holds a dropdown and no heading"}},
+ {"class-spelled, alone in the body",
+ `<body><details class="rst-dropdown"></details></body>`,
+ []string{"a dropdown sits in body, not in a row"}},
+ {"in a form that lays itself out",
+ `<div rst-lbar><form rst-search><details rst-dropdown></details></form></div>`,
+ []string{"a dropdown sits in form[rst-search], not in a row"}},
+ {"a heading inside the menu is not the section's",
+ `<div rst-box-head><details rst-dropdown><summary>S</summary><div rst-dropdown-menu><h2>P</h2></div></details></div>`,
+ []string{"a section head holds a dropdown and no heading"}},
+
+ {"in a toolbar inside a page header with no title",
+ `<header rst-page-header><div rst-lbar><details rst-dropdown><summary>Menu</summary></details></div></header>`,
+ []string{"a page header holds a dropdown and no h1"}},
+ {"class-spelled, in a toolbar inside a page header with no title",
+ `<header class="rst-page-header"><div class="rst-lbar"><details class="rst-dropdown"><summary>Menu</summary></details></div></header>`,
+ []string{"a page header holds a dropdown and no h1"}},
+ {"in a toolbar inside a section head with no heading",
+ `<div rst-box-head><div rst-lbar><details rst-dropdown><summary>Menu</summary></details></div></div>`,
+ []string{"a section head holds a dropdown and no heading"}},
+ {"class-spelled, in a toolbar inside a section head with no heading",
+ `<div class="rst-box-head"><div class="rst-lbar"><details class="rst-dropdown"><summary>Menu</summary></details></div></div>`,
+ []string{"a section head holds a dropdown and no heading"}},
+ {"a class list the respeller cannot read",
+ `<main rst-page><details class=rst-dropdown{{.Open}}></details></main>`,
+ []string{`a class list could not be respelled, so the check cannot read it: <details class="rst-dropdown{{.Open}}">`}},
+
+ {"escaped markup in a code sample",
+ `<p><code>class=&quot;rst-box&quot;</code></p>`, nil},
+ {"in a toolbar inside a page header with its title",
+ `<header rst-page-header><h1>T</h1><div rst-lbar><details rst-dropdown><summary>Menu</summary></details></div></header>`, nil},
+ {"class-spelled, in a toolbar inside a section head with its heading",
+ `<div class="rst-box-head"><h2>P</h2><div class="rst-lbar"><details class="rst-dropdown"><summary>Menu</summary></details></div></div>`, nil},
+ {"class-spelled, in a section head with its heading",
+ `<div class="rst-box-head"><h2>P</h2><details rst-dropdown></details></div>`, nil},
+ {"in a toolbar, inside a form, after the toolbar's search",
+ `<div rst-lbar><search><form rst-search method="get" action="/posts"><input type="search" name="q"></form></search>` +
+ `<form method="get" action="/posts"><details rst-dropdown><summary>S</summary></details></form></div>`, nil},
+ {"in a toolbar, inside a search's form",
+ `<div rst-lbar><search><form method="get"><details rst-dropdown></details></form></search></div>`, nil},
+ {"in the shell's bar, before the page header",
+ `<div rst-shell-tail><details rst-dropdown></details></div><main rst-page>` + header + `</main>`, nil},
+ {"in a menu in the rail's foot, before the page header",
+ `<div rst-shell-rail-foot><details rst-dropdown><summary>S</summary><div rst-dropdown-menu><details rst-dropdown></details></div></details></div><main rst-page>` + header + `</main>`, nil},
+ {"inside another dropdown's menu",
+ `<div rst-box-head><h2>P</h2><details rst-dropdown><summary>S</summary><div rst-dropdown-menu><details rst-dropdown></details></div></details></div>`, nil},
+ {"in the page header beside its title",
+ `<main rst-page><header rst-page-header><div><h1>T</h1></div><details rst-dropdown></details></header></main>`, nil},
+ {"in the footer row, after the page header",
+ `<main rst-page>` + header + `<footer rst-page-foot><details rst-dropdown></details></footer></main>`, nil},
+ }
+ for _, c := range cases {
+ if got := Breaches(c.markup, nil); !slices.Equal(got, c.want) {
+ t.Errorf("%s:\n got %q\n want %q", c.name, got, c.want)
+ }
+ }
+}
+
+// TestAPageNamesItsOwnRows: a page that lays out its own bars names
+// them by class, and a menu in one of them is in a row. A name a menu's
+// row does not carry, or one written as an attribute, is no row: the
+// page's names extend Fiesta's rows, they do not loosen the rule for
+// the rest of the page.
+func TestAPageNamesItsOwnRows(t *testing.T) {
+ bar := map[string]string{"site-bar": "the site's theme bar"}
+ cases := []struct {
+ name, markup string
+ rows map[string]string
+ want []string
+ }{
+ {"in a row the page names",
+ `<div class="site-stage site-bar"><span>Theme</span><details rst-dropdown></details></div>`, bar, nil},
+ {"in a form inside a row the page names",
+ `<nav class="site-bar"><form method="post"><details rst-dropdown></details></form></nav>`, bar, nil},
+ {"in a div the page does not name",
+ `<div class="site-stage"><details rst-dropdown></details></div>`, bar,
+ []string{"a dropdown sits in div, not in a row"}},
+ {"the name written as an attribute",
+ `<div site-bar><details rst-dropdown></details></div>`, bar,
+ []string{"a dropdown sits in div, not in a row"}},
+ {"a row the page names, with no names passed",
+ `<div class="site-bar"><details rst-dropdown></details></div>`, nil,
+ []string{"a dropdown sits in div, not in a row"}},
+ {"a Fiesta row, beside the page's names",
+ `<div rst-lbar><details rst-dropdown></details></div>`, bar, nil},
+ }
+ for _, c := range cases {
+ if got := Breaches(c.markup, c.rows); !slices.Equal(got, c.want) {
+ t.Errorf("%s:\n got %q\n want %q", c.name, got, c.want)
+ }
+ }
+}
diff --git a/internal/www/landing_rows_browser_test.go b/internal/www/landing_rows_browser_test.go
new file mode 100644
index 0000000..3db96c4
--- /dev/null
+++ b/internal/www/landing_rows_browser_test.go
@@ -0,0 +1,198 @@
+//go:build browser
+
+package www_test
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/chromedp/cdproto/accessibility"
+ "github.com/chromedp/cdproto/cdp"
+ "github.com/chromedp/cdproto/emulation"
+ "github.com/chromedp/chromedp"
+
+ "amadan.net/rastrillo/fiesta/internal/rowrule"
+ "amadan.net/rastrillo/fiesta/internal/www"
+)
+
+// landingRows are the landing page's own bars, which hold its two
+// menus. The page lays them out itself rather than with a Fiesta row,
+// so it names them to the Row Rule's check by class.
+var landingRows = map[string]string{
+ "site-nav": "the top bar: the page's links, the language menu and Get started",
+ "site-themebar": "the theme bar: the theme buttons and the colour-scheme menu",
+}
+
+// rowWidths are a 320px phone, the 390px phone the bar first wrapped
+// on, and a desktop.
+var rowWidths = []int64{320, 390, 1280}
+
+// menuLinesJS measures every menu outside another menu's panel: the
+// row it sits in, and how many of that row's other children share a
+// line with its summary. A child with no box, or a 1px one like a
+// visually hidden label, is not on the line. Then the theme bar, which
+// must be one line: each piece it shows, with its top and bottom.
+const menuLinesJS = `((rows) => {
+ const seen = r => r.width > 1 && r.height > 1;
+ const name = e => e.tagName.toLowerCase() + [...e.attributes].filter(a => a.name.startsWith("rst-") || a.name === "class").map(a => a.name === "class" ? "." + a.value.trim().split(/\s+/).join(".") : "[" + a.name + "]").join("");
+ const menus = [];
+ for (const d of document.querySelectorAll("details[rst-dropdown]")) {
+ if (d.parentElement.closest("[rst-dropdown-menu]")) continue;
+ const s = d.querySelector(":scope > summary").getBoundingClientRect();
+ const row = d.parentElement.closest(rows.map(c => "." + c).join(","));
+ const others = row ? [...row.children].filter(c => !c.contains(d)).map(c => c.getBoundingClientRect()).filter(seen) : [];
+ menus.push({Menu: name(d), Row: row ? name(row) : "", Shown: seen(s), Beside: others.filter(r => r.top < s.bottom - 1 && s.top < r.bottom - 1).length, Others: others.length, Top: s.top, Bottom: s.bottom});
+ }
+ const bar = document.querySelector(".site-themebar");
+ const parts = bar ? [bar.querySelector(".site-themelabel"), ...bar.querySelectorAll("[data-site-theme]"), bar.querySelector("[rst-scheme] > summary")].filter(e => e) : [];
+ const shown = parts.map(e => ({Text: e.textContent.trim(), R: e.getBoundingClientRect()})).filter(p => seen(p.R));
+ return JSON.stringify({Menus: menus, VW: document.documentElement.clientWidth, Bar: bar ? bar.getBoundingClientRect().width : 0,
+ Parts: shown.map(p => ({Text: p.Text, Top: p.R.top, Bottom: p.R.bottom, Width: p.R.width}))});
+})`
+
+type menuLine struct {
+ Menu, Row string
+ Shown bool
+ Beside, Others int
+ Top, Bottom float64
+}
+
+type barPart struct {
+ Text string
+ Top, Bottom, Width float64
+}
+
+type menuLines struct {
+ Menus []menuLine
+ VW, Bar float64
+ Parts []barPart
+}
+
+// oneLine reports whether the parts share a line: some height that
+// every one of them spans.
+func oneLine(parts []barPart) bool {
+ if len(parts) == 0 {
+ return false
+ }
+ top, bottom := parts[0].Top, parts[0].Bottom
+ for _, p := range parts[1:] {
+ top, bottom = max(top, p.Top), min(bottom, p.Bottom)
+ }
+ return top < bottom-1
+}
+
+// axName is the accessible name Chromium gives the element sel finds.
+func axName(t *testing.T, ctx context.Context, sel string) string {
+ t.Helper()
+ var nodes []*cdp.Node
+ mustRun(t, ctx, chromedp.Nodes(sel, &nodes, chromedp.ByQuery))
+ var name string
+ mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error {
+ ax, err := accessibility.GetPartialAXTree().WithBackendNodeID(nodes[0].BackendNodeID).WithFetchRelatives(false).Do(c)
+ if err != nil {
+ return err
+ }
+ if len(ax) > 0 && ax[0].Name != nil {
+ return json.Unmarshal(ax[0].Name.Value, &name)
+ }
+ return nil
+ }))
+ return name
+}
+
+// TestTheLandingKeepsTheRowRule: the landing page keeps the Row Rule
+// that DESIGN.md sets and the gallery's examples are held to, in every
+// language the site builds, Arabic right to left, at each width in
+// rowWidths. Each menu sits in a row, by the same check the examples
+// pass; each shares a line with something else in that row, which only
+// a browser at that width can say; and the theme bar keeps all it
+// shows, its label, its three buttons and the scheme menu, on one line.
+// The bar wrapping the scheme menu onto a line of its own on a phone is
+// the shape the rule forbids, on the page that sells Fiesta. A phone has
+// room for the bar only with its words out of sight, so the buttons'
+// group and the scheme menu are held to their names at every width:
+// hidden from sight, the words must still be read out.
+func TestTheLandingKeepsTheRowRule(t *testing.T) {
+ rig, _ := siteRig(t)
+ ctx, cancel := context.WithTimeout(rig.Context(), 5*time.Minute)
+ defer cancel()
+ rowsJS, err := json.Marshal(func() []string {
+ var classes []string
+ for c := range landingRows {
+ classes = append(classes, c)
+ }
+ return classes
+ }())
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, lang := range www.Languages {
+ mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error {
+ return emulation.SetDeviceMetricsOverride(rowWidths[0], 800, 1, false).Do(c)
+ }), chromedp.Navigate(rig.Origin+pageURL(lang)), chromedp.WaitReady("body"),
+ chromedp.Evaluate(`new Promise(r => document.readyState === "complete" ? r() : addEventListener("load", r, {once: true}))`, nil, awaitPromise),
+ soon(".site-themebar"))
+ var got, dir, page string
+ mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.lang`, &got), chromedp.Evaluate(`document.documentElement.dir`, &dir),
+ chromedp.Evaluate(`document.documentElement.outerHTML`, &page))
+ if got != lang || dir != www.Dir(lang) {
+ t.Fatalf("%s: the page is in %q, written %q; want %s, %s", pageURL(lang), got, dir, lang, www.Dir(lang))
+ }
+ for _, msg := range rowrule.Breaches(page, landingRows) {
+ t.Errorf("%s: %s", lang, msg)
+ }
+ words, err := www.Strings(siteSource(t).FS(), lang)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, w := range rowWidths {
+ mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error {
+ return emulation.SetDeviceMetricsOverride(w, 800, 1, false).Do(c)
+ }))
+ var m menuLines
+ at(t, ctx, menuLinesJS+"("+string(rowsJS)+")", &m)
+ where := fmt.Sprintf("%s at %d", lang, w)
+ // A page whose menus the walk cannot find passes whatever
+ // the rule says.
+ if len(m.Menus) != 2 || len(m.Parts) < 4 {
+ t.Fatalf("%s: found %d menus and %d theme bar pieces; want the language and scheme menus, and at least the three buttons and the menu", where, len(m.Menus), len(m.Parts))
+ }
+ for _, menu := range m.Menus {
+ switch {
+ case !menu.Shown:
+ t.Errorf("%s: %s is not shown", where, menu.Menu)
+ case menu.Row == "":
+ t.Errorf("%s: %s is in none of the page's rows", where, menu.Menu)
+ case menu.Beside == 0:
+ t.Errorf("%s: %s sits on a line of its own (%.0f to %.0f), beside none of the other %d things in %s", where, menu.Menu, menu.Top, menu.Bottom, menu.Others, menu.Row)
+ }
+ }
+ if !oneLine(m.Parts) {
+ var parts []string
+ for _, p := range m.Parts {
+ parts = append(parts, fmt.Sprintf("%q %.0f to %.0f, %.0fpx wide", p.Text, p.Top, p.Bottom, p.Width))
+ }
+ t.Errorf("%s: the theme bar (%.0fpx in a %.0fpx page) is more than one line:\n %s", where, m.Bar, m.VW, strings.Join(parts, "\n "))
+ }
+ for _, c := range []struct{ sel, key string }{
+ {".site-themebar [rst-seg-tabs]", "theme.label"},
+ {"[rst-scheme] > summary", "scheme.label"},
+ } {
+ if got := axName(t, ctx, c.sel); got != words[c.key] {
+ t.Errorf("%s: %s is named %q; want %q, its %s", where, c.sel, got, words[c.key], c.key)
+ }
+ }
+ }
+ }
+ if t.Failed() {
+ var rows []string
+ for row, why := range landingRows {
+ rows = append(rows, "."+row+" ("+why+")")
+ }
+ t.Logf("each menu belongs on a line in one of these rows: %s", strings.Join(rows, ", "))
+ }
+}
diff --git a/site/landing/page.html.tmpl b/site/landing/page.html.tmpl
index dc28067..586e8ca 100644
--- a/site/landing/page.html.tmpl
+++ b/site/landing/page.html.tmpl
@@ -69,7 +69,7 @@
<span class="site-themelabel" id="site-theme-label">{{.T "theme.label"}}</span>
<div rst-seg-tabs role="group" aria-labelledby="site-theme-label"><button type="button" data-site-theme="day" aria-pressed="true" translate="no">{{.T "theme.day"}}</button><button type="button" data-site-theme="plain" aria-pressed="false" translate="no">{{.T "theme.plain"}}</button><button type="button" data-site-theme="signal" aria-pressed="false" translate="no">{{.T "theme.signal"}}</button></div>
<details rst-dropdown rst-scheme name="rst-menus">
- <summary>{{.T "scheme.label"}}<span rst-caret aria-hidden="true"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></span></summary>
+ <summary><svg class="icon site-scheme-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg><span class="site-scheme-name">{{.T "scheme.label"}}</span><span rst-caret aria-hidden="true"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></span></summary>
<div rst-dropdown-menu role="group" aria-label="{{.T "scheme.label"}}">
<form method="post"><input type="hidden" name="scheme" value="light"><input type="hidden" name="return" value="/"><button type="submit">{{.T "scheme.light"}}</button></form>
<form method="post"><input type="hidden" name="scheme" value="dark"><input type="hidden" name="return" value="/"><button type="submit">{{.T "scheme.dark"}}</button></form>
diff --git a/site/static/site.css b/site/static/site.css
index 2294419..2c8d51f 100644
--- a/site/static/site.css
+++ b/site/static/site.css
@@ -48,6 +48,10 @@
.site-themebar[hidden] { display: none; }
.site-themebar [rst-seg-tabs] { margin: 0; }
.site-themelabel { color: var(--rst-text-muted); }
+/* The moon stands in for the scheme menu's name on a phone only. */
+@media (min-width: 561px) {
+ .site-scheme-icon { display: none; }
+}
/* The sample: a small app window built only from Fiesta attributes. */
.site-window { background: var(--rst-surface); border: 1px solid var(--rst-line); border-radius: 10px; box-shadow: var(--rst-shadow-pop); overflow: hidden; text-align: start; }
@@ -122,6 +126,13 @@
follows the viewport down to 1.9rem (240px at 320). */
.site-hero h1 { font-size: clamp(1.9rem, 9.5vw, 2.6rem); }
.site-stage { padding-inline: 0; }
- .site-themebar { padding-inline: 14px; }
+ /* The theme bar stays one row, as the Row Rule asks: on a phone the
+ label, three buttons and a worded menu need about 475px in Portuguese.
+ So the label and the menu's name are hidden from sight but stay
+ their group's and their summary's names, the moon shows instead of
+ the name, and the gaps close up. */
+ .site-themebar { padding-inline: 14px; gap: 8px; }
+ .site-themebar [rst-seg-tabs] > button { padding-inline: 10px; }
+ .site-themelabel, .site-scheme-name { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.site-window { border-inline: 0; border-radius: 0; }
}