Refuse the other command's directory, hold a frame's autofocus, and close four test gaps
The .fiesta stamp now says which command wrote it, and claim reads
it: a directory stamped by the other command is refused, as is one
with a stamp at any depth below it that this command did not write.
Both refusals name the stamp that stopped them. The review reproduced
the two ways the commands emptied each other: fiesta gallery pointed
at dist/<version> replaced a release, and gallery, then build, then
gallery again under one directory wiped the release built inside it.
TestGalleryRefusesARelease and TestGalleryRefusesADirectoryHoldingARelease
drive the real commands through run() and were red against 788a59d
("fiesta gallery emptied .../1.0.0, a release fiesta build wrote")
before the change. The stamp's sentence is unchanged; the refusals are
new error text for the CLI copy batch.
frame.js holds a framed document's autofocus, as rastrillo's gallery.js
did: each field that asks is inert from insertion to the first
animation frame after load, so a lazy frame cannot take the reader's
focus or scroll the page to itself. TestFramesKeepTheReadersPlace
loads the signin page's twelve frames, five of them asking, and was
red before the port: "focus is on the parent's iframe, not its body".
Four test gaps from the review: TestSampleURLsStayInTheMarkup renders
at /x and requires every href and action in a frame body to be a
fragment; TestTheGalleryCarriesNoPlaceholderCopy reads copy.go's table
as well as the render, and fails on an empty render;
TestRTLDocumentsReadRightToLeft holds every rtl/ document to lang=ar
dir=rtl and every other to lang=en dir=ltr; and
TestGalleryWritesTheTreeAtItsMount drives fiesta gallery -out -mount
into scratch, file for file against Render.
The brief's :focus-visible control, run as written in a scratch copy
(only the pseudo-class removed from the two field ring rules, so the
author outline applies all the time): both walks go red at every
stop, TestA11yWalksTheKeyboard at all 22 and TestA11yWalksTheStageShell
at the email field. The earlier claim that it could not go red was
wrong: that run had renamed the pseudo-class, which drops the rule
and leaves the browser's own ring.
Test labels 296284f's body left out: TestLightHalfSplitsOnlyAWholeValue,
TestEveryThemeHasAPaletteAndNothingIsOther and
TestTheShippedTreeCarriesNoAxe are new tests over code that already
existed and passed on their first run; each now has a recorded
control in a scratch copy: lightHalf returning the whole value reddens
the first two, dropping --rst-rail-bg from its group reddens the
second ("1 tokens fell through to other"), and axe.min.js copied into
js/ reddens the third.
Two stale comments are corrected: the axe gate's header no longer says
the gallery page's labels are under review, and the stage walk's
comment no longer names the rulings.
Human
Prompt
Task 13, fix round 1 of 5: fix the shared .fiesta stamp so neither command empties the other's output, with a test per scenario red against 788a59d; run the brief's :focus-visible control exactly as written; port the autofocus hold with a browser assertion; close the listed test gaps; fix stale comments and record the missing test labels.
From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.
Summary
claim refuses the other command's stamp and any stamp below the directory; frame.js holds autofocus; five new tests (two stamp scenarios, the subcommand, RTL attributes, the autofocus drive); two tests tightened; two comments corrected.
Agent notes
The refusal messages in claim.go are new CLI text and belong in Task 15's copy batch with the stamp sentence. land() from rastrillo's gallery.js is still not ported: every transition in css/ is on a registered number property, which a scheme flip does not start.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
7 files changed,
+338
−20
cmd/fiesta/claim.go+61 −7cmd/fiesta/claim_test.go+65 −1cmd/fiesta/gallery_test.go+67 −0internal/gallery/a11y_browser_test.go+50 −2internal/gallery/frame.js+42 −1internal/gallery/gallery_test.go+50 −6internal/gallery/keyboard_browser_test.go+3 −3
diff --git a/cmd/fiesta/claim.go b/cmd/fiesta/claim.go| index 4f9031f..feb7d18 100644 |
| --- a/cmd/fiesta/claim.go |
| +++ b/cmd/fiesta/claim.go |
| @@ -2,8 +2,10 @@ package main |
| import ( |
| "fmt" |
| + "io/fs" |
| "os" |
| "path/filepath" |
| + "regexp" |
| ) |
| // The ownership check is rastrillo's dsgen's, with the stamp renamed. |
| @@ -15,10 +17,23 @@ import ( |
| // stampName is the file fiesta build and fiesta gallery leave in a |
| // directory to say the directory is its own. Dotted so it stays out of |
| -// the way of whatever serves the tree. One name for both commands: a |
| -// directory one of them wrote is a directory the other may empty. |
| +// the way of whatever serves the tree. One name for both commands, and |
| +// the body says which wrote it: a directory one of them wrote is not |
| +// a directory the other may empty. |
| const stampName = ".fiesta" |
| +// stampWriter reads the command a stamp names. |
| +var stampWriter = regexp.MustCompile(`written by (fiesta [a-z]+),`) |
| + |
| +// writerOf is the command a stamp's body names, or "" when it names |
| +// none. |
| +func writerOf(body []byte) string { |
| + if m := stampWriter.FindSubmatch(body); m != nil { |
| + return string(m[1]) |
| + } |
| + return "" |
| +} |
| + |
| // stampBody is what the stamp says, naming the command that wrote it. |
| func stampBody(command string) string { |
| return "This directory is written by " + command + ", which EMPTIES it on every\n" + |
| @@ -31,10 +46,16 @@ func stampBody(command string) string { |
| // |
| // Ownership is the whole of the safety story, and it is deliberately |
| // conservative: an absent directory is created, an empty one is |
| -// adopted, one holding the stamp is emptied and reused, and anything |
| -// else is refused with an explanation. So the command can be pointed |
| -// at the wrong directory, and the wrong directory is almost never |
| -// empty. |
| +// adopted, one holding this command's own stamp and no other stamp |
| +// anywhere below it is emptied and reused, and anything else is |
| +// refused with an explanation. So the command can be pointed at the |
| +// wrong directory, and the wrong directory is almost never empty. |
| +// |
| +// The other command's stamp counts as anything else, at the root and |
| +// at any depth: fiesta build writes DIR/<version>/ and fiesta gallery |
| +// writes DIR, so a gallery pointed at a release, or at a directory a |
| +// release has since been built inside, would otherwise empty the |
| +// release. Both are refused, naming the stamp that stopped them. |
| // |
| // Emptying rather than removing named paths is the other half. |
| // Removing only what the current build produces looks safer and is |
| @@ -58,10 +79,26 @@ func claim(root, command string) error { |
| case err != nil: |
| return fmt.Errorf("reading %s: %w", root, err) |
| case len(entries) > 0: |
| - if _, err := os.Stat(filepath.Join(root, stampName)); err != nil { |
| + stamp := filepath.Join(root, stampName) |
| + body, err := os.ReadFile(stamp) |
| + if err != nil { |
| return fmt.Errorf("refusing to empty %s: it is not empty and holds no %s stamp, "+ |
| "so %s has not written it before. Empty it yourself, or point -out somewhere else", root, stampName, command) |
| } |
| + if by := writerOf(body); by != command { |
| + if by == "" { |
| + return fmt.Errorf("refusing to empty %s: its %s stamp names no command, so it may be something else's. "+ |
| + "Empty it yourself, or point -out somewhere else", root, stamp) |
| + } |
| + return fmt.Errorf("refusing to empty %s: its %s stamp says it is written by %s, and %s would empty that command's output. "+ |
| + "Point -out somewhere else", root, stamp, by, command) |
| + } |
| + if other, err := stampBelow(root); err != nil { |
| + return fmt.Errorf("reading %s: %w", root, err) |
| + } else if other != "" { |
| + return fmt.Errorf("refusing to empty %s: %s is a stamp %s did not write, so another command's output is inside. "+ |
| + "Empty it yourself, or point -out somewhere else", root, other, command) |
| + } |
| for _, e := range entries { |
| if err := os.RemoveAll(filepath.Join(root, e.Name())); err != nil { |
| return fmt.Errorf("emptying %s: %w", root, err) |
| @@ -73,3 +110,20 @@ func claim(root, command string) error { |
| } |
| return nil |
| } |
| + |
| +// stampBelow returns the path of the first stamp under root other than |
| +// root's own, or "" when there is none. |
| +func stampBelow(root string) (string, error) { |
| + found := "" |
| + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { |
| + if err != nil { |
| + return err |
| + } |
| + if !d.IsDir() && d.Name() == stampName && filepath.Dir(p) != root { |
| + found = p |
| + return fs.SkipAll |
| + } |
| + return nil |
| + }) |
| + return found, err |
| +} |
diff --git a/cmd/fiesta/claim_test.go b/cmd/fiesta/claim_test.go| index e4df0e4..aec232a 100644 |
| --- a/cmd/fiesta/claim_test.go |
| +++ b/cmd/fiesta/claim_test.go |
| @@ -48,7 +48,7 @@ func TestBuildRefusesADirectoryItDoesNotOwn(t *testing.T) { |
| t.Errorf("the refused directory holds %d entries, want the one stray file", len(entries)) |
| } |
| - if err := os.WriteFile(filepath.Join(root, stampName), []byte("stamped by an earlier run"), 0o644); err != nil { |
| + if err := os.WriteFile(filepath.Join(root, stampName), []byte("This directory is written by fiesta build, an earlier run of it.\n"), 0o644); err != nil { |
| t.Fatal(err) |
| } |
| if err := fiestaBuild(t, out); err != nil { |
| @@ -115,3 +115,67 @@ func TestBuildNeedsItsFlag(t *testing.T) { |
| t.Error("no command at all passed") |
| } |
| } |
| + |
| +// fiestaGallery runs `fiesta gallery -out out` as main would, and |
| +// returns its error. |
| +func fiestaGallery(t *testing.T, out string) error { |
| + t.Helper() |
| + var stderr bytes.Buffer |
| + return run([]string{"gallery", "-out", out}, &stderr) |
| +} |
| + |
| +// TestGalleryRefusesARelease: `fiesta gallery -out dist/<version>`, |
| +// pointed at the directory a build wrote, is refused untouched. The |
| +// stamp there is fiesta build's, and a stamp says whose directory it |
| +// is, not merely that some command once wrote it. |
| +func TestGalleryRefusesARelease(t *testing.T) { |
| + out := t.TempDir() |
| + if err := fiestaBuild(t, out); err != nil { |
| + t.Fatal(err) |
| + } |
| + release := filepath.Join(out, fiesta.Version) |
| + err := fiestaGallery(t, release) |
| + if err == nil { |
| + t.Fatalf("fiesta gallery emptied %s, a release fiesta build wrote", release) |
| + } |
| + for _, want := range []string{release, "fiesta build"} { |
| + if !strings.Contains(err.Error(), want) { |
| + t.Errorf("the refusal does not say %q: %v", want, err) |
| + } |
| + } |
| + if _, err := os.Stat(filepath.Join(release, "fiesta.css")); err != nil { |
| + t.Errorf("the release lost fiesta.css: %v", err) |
| + } |
| + if b, err := os.ReadFile(filepath.Join(release, stampName)); err != nil || string(b) != stampBody("fiesta build") { |
| + t.Errorf("the release's stamp was rewritten: %q, %v", b, err) |
| + } |
| +} |
| + |
| +// TestGalleryRefusesADirectoryHoldingARelease: a gallery directory |
| +// that a build has since written a release inside is refused by the |
| +// next gallery run, naming the release's stamp, and the release is |
| +// left as it was. A stamp below the directory is something else's |
| +// output, whichever command's it is. |
| +func TestGalleryRefusesADirectoryHoldingARelease(t *testing.T) { |
| + out := t.TempDir() |
| + if err := fiestaGallery(t, out); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fiestaBuild(t, out); err != nil { |
| + t.Fatal(err) |
| + } |
| + release := filepath.Join(out, fiesta.Version) |
| + err := fiestaGallery(t, out) |
| + if err == nil { |
| + t.Fatalf("fiesta gallery emptied %s, which held the release %s", out, release) |
| + } |
| + if !strings.Contains(err.Error(), filepath.Join(release, stampName)) { |
| + t.Errorf("the refusal does not name %s: %v", filepath.Join(release, stampName), err) |
| + } |
| + if _, err := os.Stat(filepath.Join(release, "fiesta.css")); err != nil { |
| + t.Errorf("the release lost fiesta.css: %v", err) |
| + } |
| + if _, err := os.Stat(filepath.Join(out, "index.html")); err != nil { |
| + t.Errorf("the refused gallery directory was touched: %v", err) |
| + } |
| +} |
diff --git a/cmd/fiesta/gallery_test.go b/cmd/fiesta/gallery_test.go| new file mode 100644 |
| index 0000000..45b2ea5 |
| --- /dev/null |
| +++ b/cmd/fiesta/gallery_test.go |
| @@ -0,0 +1,67 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/gallery" |
| +) |
| + |
| +// TestGalleryWritesTheTreeAtItsMount: `fiesta gallery -out DIR -mount |
| +// /x` writes gallery.Render's tree for /x into DIR itself, file for |
| +// file, stamps DIR as its own, and the flags fail as build's do. |
| +func TestGalleryWritesTheTreeAtItsMount(t *testing.T) { |
| + out := filepath.Join(t.TempDir(), "site", "gallery") |
| + var stderr bytes.Buffer |
| + if err := run([]string{"gallery", "-out", out, "-mount", "/x"}, &stderr); err != nil { |
| + t.Fatalf("fiesta gallery: %v", err) |
| + } |
| + files, err := gallery.Render(fiesta.FS(), gallery.Options{Mount: "/x"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(files) == 0 { |
| + t.Fatal("the render is empty") |
| + } |
| + for name, want := range files { |
| + got, err := os.ReadFile(filepath.Join(out, filepath.FromSlash(name))) |
| + if err != nil { |
| + t.Errorf("the gallery did not write %s: %v", name, err) |
| + continue |
| + } |
| + if !bytes.Equal(got, want) { |
| + t.Errorf("%s on disk differs from the render", name) |
| + } |
| + } |
| + index, err := os.ReadFile(filepath.Join(out, "index.html")) |
| + if err != nil || !bytes.Contains(index, []byte(`href="/x/assets/gallery.css"`)) { |
| + t.Errorf("index.html does not link the gallery's stylesheet under /x: %v", err) |
| + } |
| + if b, err := os.ReadFile(filepath.Join(out, stampName)); err != nil || string(b) != stampBody("fiesta gallery") { |
| + t.Errorf("the stamp is not fiesta gallery's: %q, %v", b, err) |
| + } |
| + var written int |
| + err = filepath.WalkDir(out, func(p string, d os.DirEntry, err error) error { |
| + if err == nil && !d.IsDir() && filepath.Base(p) != stampName { |
| + written++ |
| + } |
| + return err |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if written != len(files) { |
| + t.Errorf("%s holds %d files besides the stamp, the render has %d", out, written, len(files)) |
| + } |
| + |
| + if err := run([]string{"gallery"}, &stderr); err == nil || !strings.Contains(err.Error(), "-out") { |
| + t.Errorf("gallery with no -out: %v", err) |
| + } |
| + if err := run([]string{"gallery", "-out", out, "extra"}, &stderr); err == nil || !strings.Contains(err.Error(), "extra") { |
| + t.Errorf("gallery with a stray argument: %v", err) |
| + } |
| +} |
diff --git a/internal/gallery/a11y_browser_test.go b/internal/gallery/a11y_browser_test.go| index 658e8d4..ee1aebf 100644 |
| --- a/internal/gallery/a11y_browser_test.go |
| +++ b/internal/gallery/a11y_browser_test.go |
| @@ -23,8 +23,9 @@ |
| // not a certificate. |
| // - axe checks what is on the screen. Every state is scanned as its |
| // own document, which is where its markup lives; the gallery page |
| -// around it is scanned by the reflow drive and the keyboard walks |
| -// and not by axe, because its labels are still under review. |
| +// around it is measured by the reflow drive and not scanned by |
| +// axe, and the keyboard walks open the state documents on their |
| +// own too. |
| // - Two criteria axe cannot see at all get their own drives: |
| // 1.4.10 Reflow (a 320px viewport that does not scroll sideways) |
| // and 2.4.7/2.1.2 (keyboard_browser_test.go's walks, which prove |
| @@ -535,6 +536,53 @@ func TestTheSchemeMenuRepaintsTheFrames(t *testing.T) { |
| rig.Screen("main", "the field page in Dark") |
| } |
| +// placeJS reports where the reader's focus and scroll are on the |
| +// gallery page, and how many of its frames hold a field that asked for |
| +// autofocus. |
| +const placeJS = `(() => { |
| + const asked = [...document.querySelectorAll(".gallery-frame")].filter(f => f.contentDocument.querySelector("[autofocus]")).length; |
| + const a = document.activeElement; |
| + return JSON.stringify({active: a ? a.tagName.toLowerCase() : "", scrollY: Math.round(scrollY), asked}); |
| +})()` |
| + |
| +// TestFramesKeepTheReadersPlace: on the signin example's page, whose |
| +// states ask for autofocus, loading every frame leaves focus on the |
| +// parent and the page where it was. A browser grants a same-origin |
| +// frame's autofocus, so without frame.js's hold the first frame to |
| +// load would take the reader's focus and scroll the page to itself. |
| +func TestFramesKeepTheReadersPlace(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return newTree(t, nil) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) |
| + defer cancel() |
| + theme := fiesta.ThemeNames()[0] |
| + |
| + mustRun(t, ctx, |
| + chromedp.EmulateViewport(1280, 900), |
| + chromedp.Navigate(rig.Origin+href(theme+"/signin.html")), |
| + chromedp.WaitReady("body"), |
| + ) |
| + eagerly(t, ctx, "signin") |
| + // Autofocus lands at a rendering update after load, so the reading |
| + // waits two frames. |
| + mustRun(t, ctx, chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))`, nil, awaitPromise)) |
| + var got struct { |
| + Active string |
| + ScrollY, Asked int |
| + } |
| + at(t, ctx, placeJS, &got) |
| + if got.Asked == 0 { |
| + t.Fatal("no frame on the signin page asks for autofocus; the check below would prove nothing") |
| + } |
| + if got.Active != "body" { |
| + t.Errorf("after the frames loaded, focus is on the parent's %s, not its body: a frame took it", got.Active) |
| + } |
| + if got.ScrollY != 0 { |
| + t.Errorf("after the frames loaded, the page is scrolled to %dpx: a frame's autofocus moved it", got.ScrollY) |
| + } |
| + t.Logf("signin: %d frames asked for autofocus; focus is on the parent's %s at %dpx", got.Asked, got.Active, got.ScrollY) |
| + rig.Screen("main", "the signin page") |
| +} |
| + |
| // mustDefault is the manifest's default state for name. |
| func mustDefault(t *testing.T, name string) string { |
| t.Helper() |
diff --git a/internal/gallery/frame.js b/internal/gallery/frame.js| index b0cb109..347a191 100644 |
| --- a/internal/gallery/frame.js |
| +++ b/internal/gallery/frame.js |
| @@ -16,7 +16,20 @@ |
| submit at the window, in the capture phase, stops the request |
| before any other listener runs, and runs them all the same: |
| fiesta.js's scheme switch sees its submit in its own capture |
| - listener on the document and applies the scheme as usual. */ |
| + listener on the document and applies the scheme as usual. |
| + |
| + Autofocus. The page around a same-origin frame grants its autofocus: |
| + a sign-in field would take the reader's focus as its lazy frame |
| + loaded, and the gallery would scroll down to it. A browser grants |
| + autofocus early in a rendering update, to the first field that asked |
| + and can take focus, and runs animation frame callbacks after. So in |
| + a frame each field that asks is inert from the parser's insertion (a |
| + microtask, before any update) to the first animation frame after |
| + load, and its request is refused. Removing the attribute does not |
| + work (the request is made on insertion), nor does hiding or inerting |
| + the frame (Chromium focuses inside it anyway; measured, in the |
| + gallery this came from). Opened in a tab of its own, the document |
| + keeps its autofocus. */ |
| (function () { |
| "use strict"; |
| @@ -35,4 +48,32 @@ |
| addEventListener("submit", function (event) { |
| event.preventDefault(); |
| }, true); |
| + |
| + if (host && host.classList.contains("gallery-frame")) { |
| + var asked = []; |
| + var hold = function (el) { |
| + if (el.hasAttribute("autofocus") && !el.inert) { |
| + el.inert = true; |
| + asked.push(el); |
| + } |
| + }; |
| + var inserted = new MutationObserver(function (records) { |
| + for (var r = 0; r < records.length; r++) { |
| + var added = records[r].addedNodes; |
| + for (var n = 0; n < added.length; n++) { |
| + if (added[n].nodeType !== 1) continue; |
| + hold(added[n]); |
| + var inner = added[n].querySelectorAll("[autofocus]"); |
| + for (var i = 0; i < inner.length; i++) hold(inner[i]); |
| + } |
| + } |
| + }); |
| + inserted.observe(document.documentElement, { childList: true, subtree: true }); |
| + addEventListener("load", function () { |
| + inserted.disconnect(); |
| + requestAnimationFrame(function () { |
| + for (var i = 0; i < asked.length; i++) asked[i].inert = false; |
| + }); |
| + }); |
| + } |
| })(); |
diff --git a/internal/gallery/gallery_test.go b/internal/gallery/gallery_test.go| index 3faf64e..510c1d4 100644 |
| --- a/internal/gallery/gallery_test.go |
| +++ b/internal/gallery/gallery_test.go |
| @@ -189,9 +189,12 @@ func htmlTab(t *testing.T, page []byte, name, state string) string { |
| // TestSampleURLsStayInTheMarkup: the routes a sample stands for are |
| // shown as written in its HTML tab, where a reader copies them from, |
| // and none of them is the href or action of anything in a frame |
| -// document, where a click or a submit would otherwise request it. |
| +// document, where a click or a submit would otherwise request it. A |
| +// frame's body carries no link of the gallery's own, so every href and |
| +// action in one is a fragment; rendered at a mount, so an absolute |
| +// route could not pass as the gallery's. |
| func TestSampleURLsStayInTheMarkup(t *testing.T) { |
| - const mount = "" |
| + const mount = "/x" |
| files := render(t, mount) |
| m := manifest(t) |
| theme := fiesta.ThemeNames()[0] |
| @@ -243,8 +246,8 @@ func TestSampleURLsStayInTheMarkup(t *testing.T) { |
| if routes[v] { |
| t.Errorf("%s: <%s %s=%q> would request a sample route", name, tag, attr, v) |
| } |
| - if !strings.HasPrefix(v, "#") && !strings.HasPrefix(v, mount+"/") { |
| - t.Errorf("%s: <%s %s=%q> is neither a fragment nor the gallery's own", name, tag, attr, v) |
| + if !strings.HasPrefix(v, "#") { |
| + t.Errorf("%s: <%s %s=%q> is not a fragment", name, tag, attr, v) |
| } |
| } |
| }) |
| @@ -259,10 +262,19 @@ func TestSampleURLsStayInTheMarkup(t *testing.T) { |
| const placeholder = "TODO-COPY:" |
| // TestTheGalleryCarriesNoPlaceholderCopy fails while any label is still |
| -// a placeholder: the render, and the stylesheet and scripts it embeds, |
| -// hold no TODO-COPY marker. |
| +// a placeholder: the table itself, label by label, since a label no |
| +// page renders today is still a label; and the render, so the |
| +// stylesheet and scripts it embeds are read too. |
| func TestTheGalleryCarriesNoPlaceholderCopy(t *testing.T) { |
| + for id, v := range text { |
| + if strings.HasPrefix(v, placeholder) { |
| + t.Errorf("%s is still a placeholder, waiting for copy review", id) |
| + } |
| + } |
| files := render(t, "") |
| + if len(files) == 0 { |
| + t.Fatal("the render is empty; the scan below would read nothing") |
| + } |
| var names []string |
| for name := range files { |
| names = append(names, name) |
| @@ -284,6 +296,38 @@ func TestTheGalleryCarriesNoPlaceholderCopy(t *testing.T) { |
| } |
| } |
| +// TestRTLDocumentsReadRightToLeft: every document under a theme's rtl/ |
| +// says lang="ar" dir="rtl" on its <html>, gallery pages and frames |
| +// alike, and every other document says lang="en" dir="ltr". The axe |
| +// leg over the twin would otherwise scan left-to-right copies. |
| +func TestRTLDocumentsReadRightToLeft(t *testing.T) { |
| + files := render(t, "") |
| + rtl, ltr := 0, 0 |
| + for name, body := range files { |
| + if !strings.HasSuffix(name, ".html") { |
| + continue |
| + } |
| + tag := htmlTag.Find(body) |
| + if tag == nil { |
| + t.Errorf("%s has no <html> tag", name) |
| + continue |
| + } |
| + want := `lang="en" dir="ltr"` |
| + if strings.Contains(name, "/"+rtlDir) { |
| + want = `lang="ar" dir="rtl"` |
| + rtl++ |
| + } else { |
| + ltr++ |
| + } |
| + if !strings.Contains(string(tag), want) { |
| + t.Errorf("%s: %s does not say %s", name, tag, want) |
| + } |
| + } |
| + if rtl == 0 || ltr == 0 { |
| + t.Fatalf("%d right-to-left and %d left-to-right documents; the loop above read one side only", rtl, ltr) |
| + } |
| +} |
| + |
| var highlightRule = regexp.MustCompile(`(?m)^(ds-[tavxs]) \{ color: var\((--rst-[a-z0-9-]+)\); \}`) |
| // halves returns the light and dark halves of a whole-value |
diff --git a/internal/gallery/keyboard_browser_test.go b/internal/gallery/keyboard_browser_test.go| index 936f061..1625ce6 100644 |
| --- a/internal/gallery/keyboard_browser_test.go |
| +++ b/internal/gallery/keyboard_browser_test.go |
| @@ -296,9 +296,9 @@ func TestA11yWalksTheKeyboard(t *testing.T) { |
| // stageDocument is the page every visitor meets first: the stage |
| // layout's default state with the signin example's default state in |
| -// its slot, through example.Slot, so the walk is over the document the |
| -// rulings name whatever the shipped layout happens to carry. It is |
| -// served beside the tree as a frame document of its own. |
| +// its slot, through example.Slot, so the walk is over that document |
| +// whatever the shipped layout happens to carry. It is served beside |
| +// the tree as a frame document of its own. |
| func stageDocument(t *testing.T, m *example.Manifest) (name string, doc []byte) { |
| t.Helper() |
| layout, err := example.Document(fiesta.FS(), "layout-stage", mustDefault(t, "layout-stage")) |