rastrillo / fiesta Public

rst-snippet: a line of code in a box that wraps on a phone, with a Copy button

A page that tells a reader to paste a line (a link tag, an install
command) needs that line on screen whole and easy to copy. The site
had its own box for this, and it wrapped only below 760px: in a narrow
card on a wide screen the URL, which has no space to break at, ran out
of the box and took the end a reader has to copy with it. rst-snippet
hugs its line up to the width it is given and wraps the code anywhere
inside it, at any width, with no media query. One click selects the
whole line (user-select: all, with Safari 17.5's prefix), which is how
it is copied with no script or no clipboard.

The Copy button is copy.js's: hidden in the markup, so with no script,
or no clipboard API, there is no button that does nothing. It is spaced
by its own margin rather than the grid's gap, which would stay behind
when the button is hidden. The status takes a row under the code when
it speaks, and while empty is clipped to 1px rather than display: none,
so the live region is in the accessibility tree before its first
message.

The empty status needs :empty, which the promise gates' grammar did
not admit. It joins :disabled as a pseudo-class that only narrows a
compound and never stands for a name; two grammar rows hold that
([rst-snippet-status]:empty passes, [rst-page]:empty is refused for
lacking a positive requirement), red before the grammar change.

Tests, red by assertion against the example with no snippet CSS:
TestTheSnippetHugsItsLineAndWrapsOnAPhone (the box 1265px wide at 1280
against 657 wanted; the code's last character outside the box and the
page 472px wide at 390 and 320) and TestTheSnippetWithNoScript (the
code's user-select auto). Its other legs, the hidden button and the
status in the tree, hold the markup and passed against the stub; each
has a named mutation that turns it red (the button's hidden attribute
dropped; the empty status display: none). TestTheSnippetExampleCopies
(driveCopy over the example's own document) is already passing by
nature, red with the example's done string changed.

The floors drive went red on the new state, as expected: "snippet/
a-line-to-copy: [rst-btn] is measured with scripts and not without".
scriptShown, beside scriptBuilt, excuses that one control on that one
example, keyed by example name and inventory selector, and an entry
that excuses nothing fails. Control: the example renamed in a scratch
manifest is red again, on both counts.

TestTheEnhancedControlsCarryThePagesStrings now reads the snippet's
data-rst-copy-* in day/ja and requires both on its button; already
passing (localAttrs writes every catalog key), red when localAttrs
skips copy- keys or the example's button carries none.

TestBothSpellingsComputeTheSameStyles failed on the three new classes
the class fixture never rendered; the fixture gains two class-spelled
snippets, one with an empty status and one speaking. copy.js joins
proseScripts here and in the exporter's twin, since the blurb names it.

The blurb and the label "A line to copy" are the approved English,
byte for byte. Their translations in the eleven gallery catalogs are
machine drafts from that English, not reviewed; drafts.json names
each. The button's sample word Copy is carried from each catalog's
gallery.copy, not drafted. Heights measured by the drives (Desktop
needs 82px; Mobile en 155, ga 176, ru 197), with the table's 20px of
slack; the twelve-locale sweep will cover the other locales.

Size, gzip level 6 (allotment 240, on target):
  fiesta.css        23,253 -> 23,412 (+159)
  fiesta-plain.css  22,538 -> 22,698 (+160)
  fiesta-signal.css 22,650 -> 22,810 (+160)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 3c9ea437ce963101c7867c82dafd975b602b6f9a parent 5cc67e5
36 files changed, +417 −19
  • check/coverage_browser_test.go +28 −3
  • check/selectors_test.go +7 −5
  • check/snippet_browser_test.go +226 −0
  • check/testdata/fixtures/spelling-class.html +2 −0
  • css/tokens.css +22 −0
  • examples/examples.json +19 −0
  • examples/prose.json +3 −0
  • examples/snippet/a-line-to-copy.html +18 −0
  • hack/seed/_rastrillo/prose_fiestaseed_test.go +1 −1
  • internal/gallery/heights.go +4 −0
  • internal/gallery/i18n/ar.json +2 −0
  • internal/gallery/i18n/bn.json +2 −0
  • internal/gallery/i18n/drafts.json +32 −0
  • internal/gallery/i18n/es.json +2 −0
  • internal/gallery/i18n/ga.json +2 −0
  • internal/gallery/i18n/hi.json +2 −0
  • internal/gallery/i18n/ja.json +2 −0
  • internal/gallery/i18n/pt-PT.json +2 −0
  • internal/gallery/i18n/ru.json +2 −0
  • internal/gallery/i18n/samples.ar.json +1 −0
  • internal/gallery/i18n/samples.bn.json +1 −0
  • internal/gallery/i18n/samples.es.json +1 −0
  • internal/gallery/i18n/samples.ga.json +1 −0
  • internal/gallery/i18n/samples.hi.json +1 −0
  • internal/gallery/i18n/samples.ja.json +1 −0
  • internal/gallery/i18n/samples.pt-PT.json +1 −0
  • internal/gallery/i18n/samples.ru.json +1 −0
  • internal/gallery/i18n/samples.vi.json +1 −0
  • internal/gallery/i18n/samples.yue.json +1 −0
  • internal/gallery/i18n/samples.zh-Hans.json +1 −0
  • internal/gallery/i18n/vi.json +2 −0
  • internal/gallery/i18n/yue.json +2 −0
  • internal/gallery/i18n/zh-Hans.json +2 −0
  • internal/gallery/localise_test.go +20 −9
  • internal/gallery/nav.go +1 −0
  • internal/gallery/prose_rule_test.go +1 −1

CI passed — run details

CI log
=== gofmt ===
=== no-rastrillo ===
go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 .
ok  	amadan.net/rastrillo/fiesta	0.205s
=== size ===
go test ./internal/build -run '^TestTheOneLinkIsUnder25KiBGzipped$' -count=1 -v
=== RUN   TestTheOneLinkIsUnder25KiBGzipped
    build_test.go:138: fiesta-plain.css: 155520 bytes, 22698 bytes gzipped (22.2 KiB of the 25.0 KiB limit)
    build_test.go:138: fiesta-signal.css: 155615 bytes, 22810 bytes gzipped (22.3 KiB of the 25.0 KiB limit)
    build_test.go:138: fiesta.css: 158434 bytes, 23412 bytes gzipped (22.9 KiB of the 25.0 KiB limit)
--- PASS: TestTheOneLinkIsUnder25KiBGzipped (0.02s)
PASS
ok  	amadan.net/rastrillo/fiesta/internal/build	0.025s
=== history ===
history: 26 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	2.276s
ok  	amadan.net/rastrillo/fiesta/check	7.573s
ok  	amadan.net/rastrillo/fiesta/example	0.139s
?   	amadan.net/rastrillo/fiesta/hack/seed/english	[no test files]
ok  	amadan.net/rastrillo/fiesta/internal/axe	0.095s
ok  	amadan.net/rastrillo/fiesta/internal/build	0.984s
ok  	amadan.net/rastrillo/fiesta/internal/codeview	0.010s
?   	amadan.net/rastrillo/fiesta/internal/harness	[no test files]
ok  	amadan.net/rastrillo/fiesta/internal/markup	0.030s
ok  	amadan.net/rastrillo/fiesta/internal/nodetest	0.311s
ok  	amadan.net/rastrillo/fiesta/internal/site	2.135s
ok  	amadan.net/rastrillo/fiesta/internal/tokens	0.014s
suite: root took 17s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m11.600000s 0m2.080000s
--- 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 36 of 108 test tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	63.576s
suite: test.internal.gallery~1of3 took 72s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m49.990000s 0m1.360000s
--- 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 108 test tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	66.069s
suite: test.internal.gallery~2of3 took 74s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m52.600000s 0m1.250000s
--- 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 108 test tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	62.277s
suite: test.internal.gallery~3of3 took 70s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m47.960000s 0m1.370000s
--- 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	45.080s
suite: test.cmd.fiesta~1of2 took 53s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m32.490000s 0m2.110000s
--- 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	50.982s
suite: test.cmd.fiesta~2of2 took 54s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m34.780000s 0m2.320000s
--- 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	60.279s
suite: test.internal.www~1of2 took 68s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m46.710000s 0m1.570000s
--- 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	58.254s
suite: test.internal.www~2of2 took 66s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m44.440000s 0m1.560000s
--- 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 11s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m13.880000s 0m1.060000s
--- shard 9 of 48 ---
==> shard 9 of 48: make govulncheck
go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./...
=== Symbol Results ===

No vulnerabilities found.

Your code is affected by 0 vulnerabilities.
This scan also found 2 vulnerabilities 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.
suite: govulncheck took 8s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m3.110000s 0m0.380000s
--- 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' .
5:53AM INF Unknown SCM platform. Use --platform to include links in findings. host=amadan.net
5:53AM INF 26 commits scanned.
5:53AM INF scanned ~8337431 bytes (8.34 MB) in 8.62s
5:53AM INF no leaks found
suite: gitleaks took 10s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m7.050000s 0m0.270000s
--- shard 11 of 48 ---
==> shard 11 of 48: make browser.internal.harness
./hack/gotest.sh browser 'internal.harness'
gotest: on ip-172-31-22-57, 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	4.589s
suite: browser.internal.harness took 13s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m1.650000s 0m1.320000s
--- shard 12 of 48 ---
==> shard 12 of 48: make browser.check~1of13
./hack/gotest.sh browser 'check~1of13'
gotest: on ip-172-31-22-57, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 1 of 13 of ./check/ runs 16 of 220 browser tests
ok  	amadan.net/rastrillo/fiesta/check	89.741s
suite: browser.check~1of13 took 101s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m46.920000s 0m13.710000s
--- shard 13 of 48 ---
==> shard 13 of 48: make browser.check~2of13
./hack/gotest.sh browser 'check~2of13'
gotest: on ip-172-31-22-57, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 2 of 13 of ./check/ runs 16 of 220 browser tests
ok  	amadan.net/rastrillo/fiesta/check	90.484s
suite: browser.check~2of13 took 101s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m49.290000s 0m13.380000s
--- shard 14 of 48 ---
==> shard 14 of 48: make browser.check~3of13
./hack/gotest.sh browser 'check~3of13'
gotest: on ip-172-31-22-57, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 3 of 13 of ./check/ runs 16 of 220 browser tests
ok  	amadan.net/rastrillo/fiesta/check	88.346s
suite: browser.check~3of13 took 99s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m39.850000s 0m11.010000s
--- shard 15 of 48 ---
==> shard 15 of 48: make browser.check~4of13
./hack/gotest.sh browser 'check~4of13'
gotest: on ip-172-31-22-57, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 4 of 13 of ./check/ runs 16 of 220 browser tests
ok  	amadan.net/rastrillo/fiesta/check	85.791s
suite: browser.check~4of13 took 97s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m51.790000s 0m15.430000s
--- shard 16 of 48 ---
==> shard 16 of 48: make browser.check~5of13
./hack/gotest.sh browser 'check~5of13'
gotest: on ip-172-31-22-57, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 5 of 13 of ./check/ runs 18 of 220 browser tests
ok  	amadan.net/rastrillo/fiesta/check	78.698s
suite: browser.check~5of13 took 89s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m38.280000s 0m13.650000s
--- shard 17 of 48 ---
==> shard 17 of 48: make browser.check~6of13
./hack/gotest.sh browser 'check~6of13'
gotest: on ip-172-31-22-57, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 6 of 13 of ./check/ runs 18 of 220 browser tests
ok  	amadan.net/rastrillo/fiesta/check	75.956s
suite: browser.check~6of13 took 87s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m42.430000s 0m14.480000s
--- shard 18 of 48 ---
==> shard 18 of 48: make browser.check~7of13
./hack/gotest.sh browser 'check~7of13'
gotest: on ip-172-31-22-57, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 7 of 13 of ./check/ runs 18 of 220 browser tests
ok  	amadan.net/rastrillo/fiesta/check	84.066s
suite: browser.check~7of13 took 95s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m43.460000s 0m14.330000s
--- shard 19 of 48 ---
==> shard 19 of 48: make browser.check~8of13
./hack/gotest.sh browser 'check~8of13'
gotest: on ip-172-31-22-57, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 8 of 13 of ./check/ runs 17 of 220 browser tests
ok  	amadan.net/rastrillo/fiesta/check	88.815s
suite: browser.check~8of13 took 100s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m44.740000s 0m15.530000s
--- shard 20 of 48 ---
==> shard 20 of 48: make browser.check~9of13
./hack/gotest.sh browser 'check~9of13'
gotest: on ip-172-31-22-57, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 9 of 13 of ./check/ runs 17 of 220 browser tests
ok  	amadan.net/rastrillo/fiesta/check	73.588s
suite: browser.check~9of13 took 84s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m29.160000s 0m9.060000s
--- shard 21 of 48 ---
==> shard 21 of 48: make browser.check~10of13
./hack/gotest.sh browser 'check~10of13'
gotest: on ip-172-31-22-57, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 10 of 13 of ./check/ runs 17 of 220 browser tests
ok  	amadan.net/rastrillo/fiesta/check	80.409s
suite: browser.check~10of13 took 91s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m37.230000s 0m12.120000s
--- shard 22 of 48 ---
==> shard 22 of 48: make browser.check~11of13
./hack/gotest.sh browser 'check~11of13'
gotest: on ip-172-31-22-57, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 11 of 13 of ./check/ runs 17 of 220 browser tests
ok  	amadan.net/rastrillo/fiesta/check	83.861s
suite: browser.check~11of13 took 95s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m41.460000s 0m13.930000s
--- shard 23 of 48 ---
==> shard 23 of 48: make browser.check~12of13
./hack/gotest.sh browser 'check~12of13'
gotest: on ip-172-31-22-57, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 12 of 13 of ./check/ runs 17 of 220 browser tests
ok  	amadan.net/rastrillo/fiesta/check	82.027s
suite: browser.check~12of13 took 93s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m43.440000s 0m15.160000s
--- shard 24 of 48 ---
==> shard 24 of 48: make browser.check~13of13
./hack/gotest.sh browser 'check~13of13'
gotest: on ip-172-31-22-57, driving /opt/amadan-browser/chromium-1234/chrome-linux/chrome
gotest: slice 13 of 13 of ./check/ runs 17 of 220 browser tests
ok  	amadan.net/rastrillo/fiesta/check	79.178s
suite: browser.check~13of13 took 90s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m37.040000s 0m11.050000s
--- 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-22-57, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 1 of 18 of ./internal/gallery/ runs 5 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	39.220s
suite: browser.internal.gallery~1of18 took 50s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m15.870000s 0m3.970000s
--- 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-22-57, 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	47.801s
suite: browser.internal.gallery~2of18 took 59s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m13.620000s 0m2.640000s
--- 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-22-57, 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	58.803s
suite: browser.internal.gallery~3of18 took 69s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m15.730000s 0m4.220000s
--- 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-22-57, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 4 of 18 of ./internal/gallery/ runs 6 of 84 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	52.035s
suite: browser.internal.gallery~4of18 took 63s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m14.280000s 0m3.140000s
--- 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-22-57, 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	58.047s
suite: browser.internal.gallery~5of18 took 69s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m18.510000s 0m5.220000s
--- 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-22-57, 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	58.274s
suite: browser.internal.gallery~6of18 took 69s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m16.060000s 0m4.150000s
--- 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-22-57, 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	55.576s
suite: browser.internal.gallery~7of18 took 66s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m13.650000s 0m2.700000s
--- 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-22-57, 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	59.334s
suite: browser.internal.gallery~8of18 took 70s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m17.940000s 0m4.770000s
--- 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-22-57, 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	65.200s
suite: browser.internal.gallery~9of18 took 76s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m22.650000s 0m7.380000s
--- 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-22-57, 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	62.154s
suite: browser.internal.gallery~10of18 took 73s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m14.090000s 0m2.800000s
--- 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-22-57, 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	62.513s
suite: browser.internal.gallery~11of18 took 73s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m14.220000s 0m2.990000s
--- 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-22-57, 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	62.560s
suite: browser.internal.gallery~12of18 took 73s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m18.700000s 0m4.820000s
--- 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-22-57, 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	64.641s
suite: browser.internal.gallery~13of18 took 75s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m19.200000s 0m5.500000s
--- 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-22-57, 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	67.455s
suite: browser.internal.gallery~14of18 took 78s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m19.540000s 0m5.570000s
--- 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-22-57, 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	67.729s
suite: browser.internal.gallery~15of18 took 78s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m19.560000s 0m5.840000s
--- 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-22-57, 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	62.027s
suite: browser.internal.gallery~16of18 took 73s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m16.290000s 0m4.340000s
--- 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-22-57, 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	67.058s
suite: browser.internal.gallery~17of18 took 78s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m19.250000s 0m5.760000s
--- 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-22-57, 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	68.514s
suite: browser.internal.gallery~18of18 took 79s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m20.500000s 0m6.460000s
--- 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-22-57, 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	31.966s
suite: browser.internal.gallery.history took 41s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m21.200000s 0m2.090000s
--- 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-22-57, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 1 of 4 of ./internal/www/ runs 4 of 22 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/www	69.548s
suite: browser.internal.www~1of4 took 80s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m31.260000s 0m2.180000s
--- 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-22-57, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 2 of 4 of ./internal/www/ runs 6 of 22 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/www	65.919s
suite: browser.internal.www~2of4 took 76s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m44.940000s 0m2.720000s
--- 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-22-57, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 3 of 4 of ./internal/www/ runs 6 of 22 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/www	80.929s
suite: browser.internal.www~3of4 took 90s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m51.770000s 0m3.050000s
--- 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-22-57, driving the harness's own choice (/usr/local/bin/chromium)
gotest: slice 4 of 4 of ./internal/www/ runs 6 of 22 browser tests
ok  	amadan.net/rastrillo/fiesta/internal/www	76.217s
suite: browser.internal.www~4of4 took 86s; CPU, user then system, of this shell and then of the role:
0m0.000000s 0m0.000000s
0m45.560000s 0m3.020000s
diff --git a/check/coverage_browser_test.go b/check/coverage_browser_test.go
index c698292..7576366 100644
--- a/check/coverage_browser_test.go
+++ b/check/coverage_browser_test.go
@@ -193,6 +193,17 @@ var scriptBuilt = map[string]string{
"[rst-dtp-row]": "datetime.js builds the suggestion list",
}
+// scriptShown are the controls one example's markup carries hidden and
+// a script shows, keyed by example name and inventory selector: with no
+// script a button that could only do nothing stays hidden, on purpose,
+// so the no-script leg allows its absence on that example alone. Keyed
+// that narrowly so a control another example needs a script to show
+// still fails, and each entry must excuse an absence, so one left
+// behind by a renamed example fails too.
+var scriptShown = map[string]string{
+ "snippet [rst-btn]": "copy.js shows the Copy button only where there is a clipboard; with no script it stays hidden on purpose",
+}
+
// floorGaps are the inventory idioms no example state renders, each
// with why, which the seeded sizing page holds instead. A new gap
// fails the drive, and so does a pinned gap that an example has since
@@ -333,8 +344,9 @@ type floorReading struct {
// - the page loads with no console error, no failed request and no
// JS value leaked to the screen (the rig's screen gate);
// - with no script, the same idioms are measured on each state as
-// with scripts, the three idioms a script builds aside, so a
-// component whose controls need a script to appear fails here.
+// with scripts, the three idioms a script builds and the one
+// control scriptShown names aside, so a component whose controls
+// need a script to appear fails here.
//
// Every inventory idiom a state renders must have been measured on it,
// so an idiom cannot drop out of the floor because its example moved;
@@ -385,6 +397,8 @@ func meetTheFloors(t *testing.T, phone bool, legs []floorLeg) {
}
seen := map[string]int{}
withScripts := map[string]map[string]bool{}
+ shownUsed := map[string]bool{}
+ noscript := false
var carets, icons int
for _, leg := range legs {
t.Run(leg.name, func(t *testing.T) {
@@ -392,6 +406,7 @@ func meetTheFloors(t *testing.T, phone bool, legs []floorLeg) {
if leg.coarse {
opts = append(opts, harness.WithCoarsePointer())
}
+ noscript = noscript || leg.noscript
rig := harness.New(t, func(string) http.Handler { return srv }, opts...)
ctx, cancel := context.WithTimeout(rig.Context(), 20*time.Minute)
defer cancel()
@@ -471,7 +486,12 @@ func meetTheFloors(t *testing.T, phone bool, legs []floorLeg) {
t.Fatalf("%s: no record of what the scripted leg measured on this state", where)
}
for sel := range with {
- if _, built := scriptBuilt[sel]; !here[sel] && !built {
+ _, built := scriptBuilt[sel]
+ _, shown := scriptShown[st.Name+" "+sel]
+ if !here[sel] && shown {
+ shownUsed[st.Name+" "+sel] = true
+ }
+ if !here[sel] && !built && !shown {
t.Errorf("%s: %s (%s) is measured with scripts and not without; the component does not work with no script", where, sel, keyed[sel])
}
}
@@ -503,6 +523,11 @@ func meetTheFloors(t *testing.T, phone bool, legs []floorLeg) {
t.Logf("%s: %d of the manifest's %d states served, %d controls measured", leg.name, len(want)-missing, len(want), measured)
})
}
+ for key := range scriptShown {
+ if noscript && !shownUsed[key] {
+ t.Errorf("scriptShown excuses %s with no script, and no state needed it; drop the entry or fix its key", key)
+ }
+ }
if carets == 0 || icons == 0 {
t.Errorf("read %d caret icons and %d other icons in controls across every state; the examples must show at least one of each", carets, icons)
}
diff --git a/check/selectors_test.go b/check/selectors_test.go
index d3cf3d7..f6160cb 100644
--- a/check/selectors_test.go
+++ b/check/selectors_test.go
@@ -21,10 +21,10 @@ import (
// - an alternative is compounds joined by descendant, > or +;
// - a compound is an optional type selector, then classes, attribute
// selectors ([a], [a="v"], [a~="v"]), the pseudo-classes :hover,
-// :active, :focus-visible, :disabled, :first-child, :last-child,
-// :nth-child(An+B), :has() and :not(), and last ::before or
-// ::after (a state like :disabled only narrows, and never stands
-// for a name);
+// :active, :focus-visible, :disabled, :empty, :first-child,
+// :last-child, :nth-child(An+B), :has() and :not(), and last
+// ::before or ::after (a state like :disabled, or :empty, only
+// narrows, and never stands for a name);
// - :is(), :where(), * and anything else are outside it, except *
// inside a :not() argument, where it can only narrow.
@@ -334,7 +334,7 @@ func (p *selParser) attr() (attrSel, error) {
func (p *selParser) pseudo(inNot bool) (pseudo, error) {
ps := pseudo{Name: strings.ToLower(p.ident())}
switch ps.Name {
- case "hover", "active", "focus-visible", "disabled", "first-child", "last-child":
+ case "hover", "active", "focus-visible", "disabled", "empty", "first-child", "last-child":
if p.peek() == '(' {
return ps, p.errorf("`:%s(` is outside the grammar", ps.Name)
}
@@ -570,6 +570,8 @@ func TestOnlyNewMarkupPassesTheGrammar(t *testing.T) {
{"[rst-page] button:disabled", false, "positively requires"},
{"[rst-snippet]", true, ""},
{"[rst-snippet] > button:disabled", true, ""},
+ {"[rst-page]:empty", false, "positively requires"},
+ {"[rst-snippet-status]:empty", true, ""},
{"[rst-page-foot]:not(:has(> *))", true, ""},
{`[rst-row]:has(> [rst-status~="inline"]) > [rst-status]`, true, ""},
{"[rst-page-foot] [rst-dropdown]", true, ""},
diff --git a/check/snippet_browser_test.go b/check/snippet_browser_test.go
new file mode 100644
index 0000000..c9cc826
--- /dev/null
+++ b/check/snippet_browser_test.go
@@ -0,0 +1,226 @@
+//go:build browser
+
+// The snippet example as a reader meets it: a line of code in a box no
+// wider than the line on a desktop and no wider than the screen on a
+// phone, where the line wraps rather than run off the edge, still left
+// to right in a right-to-left page; with no script the Copy button is
+// gone, the code is selected whole by one click, and the status is a
+// live region before it ever speaks.
+package check
+
+import (
+ "context"
+ "fmt"
+ "math"
+ "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"
+ "amadan.net/rastrillo/fiesta/example"
+)
+
+// snippetExampleDoc is the snippet example's one state as a document,
+// its assets flattened beside the page as the sizing mux serves them.
+func snippetExampleDoc(t *testing.T) string {
+ t.Helper()
+ state, ok := manifest(t).Default("snippet")
+ if !ok || state == "" {
+ t.Fatal("examples.json names no default state for snippet")
+ }
+ doc, err := example.Document(fiesta.FS(), "snippet", state)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return parityLinks(doc)
+}
+
+// snippetPages are the example at / and right to left at /rtl.
+func snippetPages(t *testing.T) map[string]string {
+ t.Helper()
+ doc := snippetExampleDoc(t)
+ return map[string]string{"/": doc, "/rtl": inArabic(t, doc)}
+}
+
+// snippetGeometry is what the layout test reads of the box: its width
+// and its container's content width, the code's one-line width (a
+// clone of it set on one line), its height and line height, the
+// button's width with its inline margins when shown, the box's column
+// gap, its paddings and borders, the rect of the code's last character, the document's
+// scroll and client widths, and the code's direction.
+type snippetGeometry struct {
+ Box, BoxLeft, BoxRight, Container float64
+ Line, CodeHeight, LineHeight, Button, Gap float64
+ Chrome float64
+ ButtonShown bool
+ LastLeft, LastRight, ScrollWidth, ClientWidth float64
+ Direction string
+}
+
+const snippetGeometryJS = `(() => {
+ const box = document.querySelector("[rst-snippet]"), code = box.querySelector("code"),
+ b = box.querySelector("[rst-snippet-copy]"), bs = getComputedStyle(box), cs = getComputedStyle(code);
+ const parent = box.parentElement, ps = getComputedStyle(parent);
+ const clone = code.cloneNode(true);
+ clone.style.cssText = "position: absolute; visibility: hidden; white-space: nowrap; inline-size: max-content";
+ box.append(clone);
+ const line = clone.getBoundingClientRect().width;
+ clone.remove();
+ const text = code.firstChild, last = document.createRange();
+ last.setStart(text, text.length - 1);
+ last.setEnd(text, text.length);
+ const lr = last.getBoundingClientRect(), r = box.getBoundingClientRect();
+ const px = (v) => parseFloat(v) || 0;
+ const lh = cs.lineHeight === "normal" ? 1.2 * px(cs.fontSize) : px(cs.lineHeight);
+ return JSON.stringify({
+ Box: r.width, BoxLeft: r.left, BoxRight: r.right,
+ Container: parent.clientWidth - px(ps.paddingLeft) - px(ps.paddingRight),
+ Line: line, CodeHeight: code.getBoundingClientRect().height, LineHeight: lh,
+ ButtonShown: b.getClientRects().length > 0,
+ Button: b.getBoundingClientRect().width + px(getComputedStyle(b).marginLeft) + px(getComputedStyle(b).marginRight),
+ Gap: px(bs.columnGap),
+ Chrome: px(bs.paddingLeft) + px(bs.paddingRight) + px(bs.borderLeftWidth) + px(bs.borderRightWidth),
+ LastLeft: lr.left, LastRight: lr.right,
+ ScrollWidth: document.documentElement.scrollWidth, ClientWidth: document.documentElement.clientWidth,
+ Direction: cs.direction});
+})()`
+
+// TestTheSnippetHugsItsLineAndWrapsOnAPhone: at 1280 the box is as
+// wide as the line of code, the Copy button and the box's own gap,
+// padding and border, with the code on one line, so a short line does
+// not sit in a box the width of the page. At 390 and 320 the box is no
+// wider than its container and the code wraps inside it, its last
+// character still in the box and the page never wider than the
+// screen: the line a reader is told to copy is all on screen. Right to
+// left, the code still reads left to right.
+func TestTheSnippetHugsItsLineAndWrapsOnAPhone(t *testing.T) {
+ rig := sizingRig(t, false, snippetPages(t))
+ ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
+ defer cancel()
+ grantClipboard(t, ctx, rig.Origin)
+ for _, w := range []int64{1280, 390, 320} {
+ for _, path := range []string{"/", "/rtl"} {
+ where := fmt.Sprintf("%s at %d", path, w)
+ mustRun(t, ctx, chromedp.EmulateViewport(w, 800), chromedp.Navigate(rig.Origin+path), chromedp.WaitReady("body", chromedp.ByQuery))
+ settleUntil(t, ctx, `document.readyState === "complete"`)
+ var g snippetGeometry
+ at(t, ctx, snippetGeometryJS, &g)
+ if !g.ButtonShown {
+ t.Fatalf("%s: the Copy button is not shown with a clipboard; the widths below would leave it out", where)
+ }
+ if g.Direction != "ltr" {
+ t.Errorf("%s: the code's direction is %s; code reads left to right in any page", where, g.Direction)
+ }
+ if w == 1280 {
+ want := g.Line + g.Gap + g.Button + g.Chrome
+ if math.Abs(g.Box-want) > 2 {
+ t.Errorf("%s: the box is %.1fpx wide; want %.1fpx, the line (%.1f), the gap (%.1f), the button (%.1f) and the padding and border (%.1f)", where, g.Box, want, g.Line, g.Gap, g.Button, g.Chrome)
+ }
+ if g.CodeHeight > 1.5*g.LineHeight {
+ t.Errorf("%s: the code is %.1fpx tall, its line %.1fpx; at this width it fits on one line", where, g.CodeHeight, g.LineHeight)
+ }
+ continue
+ }
+ if g.Box > g.Container+0.5 {
+ t.Errorf("%s: the box is %.1fpx wide in a container of %.1fpx", where, g.Box, g.Container)
+ }
+ if g.CodeHeight < 1.5*g.LineHeight {
+ t.Errorf("%s: the code is %.1fpx tall, its line %.1fpx; a line this long must wrap on a phone", where, g.CodeHeight, g.LineHeight)
+ }
+ if g.LastLeft < g.BoxLeft-0.5 || g.LastRight > g.BoxRight+0.5 {
+ t.Errorf("%s: the code's last character spans %.1f to %.1f, outside the box's %.1f to %.1f", where, g.LastLeft, g.LastRight, g.BoxLeft, g.BoxRight)
+ }
+ if g.ScrollWidth > g.ClientWidth {
+ t.Errorf("%s: the page is %.0fpx wide in a %.0fpx viewport; the code must wrap, not scroll sideways", where, g.ScrollWidth, g.ClientWidth)
+ }
+ }
+ }
+}
+
+// statusInTheTree says whether the element sel finds is a status node
+// in the accessibility tree: a live region that is not in the tree
+// when its first message arrives may never be announced.
+func statusInTheTree(t *testing.T, ctx context.Context, sel string) bool {
+ t.Helper()
+ var nodes []*cdp.Node
+ mustRun(t, ctx, chromedp.Nodes(sel, &nodes, chromedp.ByQuery))
+ // Read loosely: cdproto's Node refuses an ignored reason it has no
+ // constant for (Chromium's "uninteresting"), and this needs only
+ // the role, the ignored flag and the DOM node.
+ var tree struct {
+ Nodes []struct {
+ Ignored bool `json:"ignored"`
+ Role *struct {
+ Value any `json:"value"`
+ } `json:"role"`
+ BackendDOMNodeID cdp.BackendNodeID `json:"backendDOMNodeId"`
+ } `json:"nodes"`
+ }
+ mustRun(t, ctx, accessibility.Enable(), chromedp.ActionFunc(func(ctx context.Context) error {
+ return cdp.Execute(ctx, accessibility.CommandGetFullAXTree, accessibility.GetFullAXTree(), &tree)
+ }))
+ if len(tree.Nodes) == 0 {
+ t.Fatal("the accessibility tree came back empty; the read is broken, not the status")
+ }
+ for _, n := range tree.Nodes {
+ if n.BackendDOMNodeID == nodes[0].BackendNodeID {
+ return !n.Ignored && n.Role != nil && n.Role.Value == "status"
+ }
+ }
+ return false
+}
+
+// TestTheSnippetWithNoScript: with scripts off there is no Copy
+// button to press and do nothing; the code is selected whole by one
+// click or tap (user-select: all), so it can still be copied by hand;
+// and the empty status takes no room, though it is a status node in
+// the accessibility tree, clipped rather than display: none.
+func TestTheSnippetWithNoScript(t *testing.T) {
+ rig := sizingRig(t, false, snippetPages(t))
+ ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
+ defer cancel()
+ mustRun(t, ctx, emulation.SetScriptExecutionDisabled(true))
+ for _, path := range []string{"/", "/rtl"} {
+ mustRun(t, ctx, chromedp.EmulateViewport(1280, 800), chromedp.Navigate(rig.Origin+path), chromedp.WaitReady("body", chromedp.ByQuery))
+ var r struct {
+ Ran, Shown bool
+ Width, Height float64
+ UserSelect, WebkitSelect string
+ }
+ at(t, ctx, `(() => {
+ const box = document.querySelector("[rst-snippet]"), s = box.querySelector("[rst-snippet-status]").getBoundingClientRect(),
+ cs = getComputedStyle(box.querySelector("code"));
+ return JSON.stringify({Ran: window.fiestaCopy === true, Shown: box.querySelector("[rst-snippet-copy]").getClientRects().length > 0,
+ Width: s.width, Height: s.height, UserSelect: cs.userSelect, WebkitSelect: cs.webkitUserSelect}); })()`, &r)
+ if r.Ran {
+ t.Fatalf("%s: copy.js ran with scripts off; this leg is not the page a reader with no script gets", path)
+ }
+ if r.Shown {
+ t.Errorf("%s: with no script the Copy button is displayed; it could only do nothing", path)
+ }
+ if r.Width > 1 || r.Height > 1 {
+ t.Errorf("%s: the empty status is %.1f by %.1f; while it has nothing to say it takes no room", path, r.Width, r.Height)
+ }
+ if r.UserSelect != "all" && r.WebkitSelect != "all" {
+ t.Errorf("%s: the code's user-select is %q (-webkit- %q); want all, so one click selects the whole line to copy by hand", path, r.UserSelect, r.WebkitSelect)
+ }
+ if !statusInTheTree(t, ctx, "[rst-snippet-status]") {
+ t.Errorf("%s: the empty status is not a status node in the accessibility tree", path)
+ }
+ }
+}
+
+// TestTheSnippetExampleCopies: the gallery's own snippet document takes
+// driveCopy's journey, so what the gallery shows is markup copy.js
+// works on, with the example's own strings.
+func TestTheSnippetExampleCopies(t *testing.T) {
+ rig := sizingRig(t, false, snippetPages(t))
+ ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
+ defer cancel()
+ driveCopy(t, ctx, rig.Origin, "/")
+ rig.Screen("body", "the snippet example after two copies")
+}
diff --git a/check/testdata/fixtures/spelling-class.html b/check/testdata/fixtures/spelling-class.html
index cb68515..3468158 100644
--- a/check/testdata/fixtures/spelling-class.html
+++ b/check/testdata/fixtures/spelling-class.html
@@ -66,6 +66,8 @@
<div class="rst-job" ><span class="rst-spin" aria-hidden="true"></span> <strong>Import</strong> is running — 128 of 400…<progress class="rst-job__bar" value="32" min="0" max="100" aria-hidden="true"></progress></div>
<dl class="rst-detail" ><dt>Reference</dt><dd class="rst-mono" >AB-1001</dd></dl>
+ <div class="rst-snippet" ><code dir="ltr" translate="no">&lt;link rel="stylesheet" href="https://fiesta.rastrillo.org/1/fiesta.css"&gt;</code><button class="rst-btn rst-btn--sm rst-snippet__copy" type="button">Copy</button><span class="rst-snippet__status" role="status"></span></div>
+ <div class="rst-snippet" ><code dir="ltr" translate="no">fiesta.css</code><button class="rst-btn rst-btn--sm rst-snippet__copy" type="button">Copy</button><span class="rst-snippet__status" role="status">Copied</span></div>
<form class="rst-form" method="post" action="#a">
<div class="rst-form-flow" >
diff --git a/css/tokens.css b/css/tokens.css
index 0c6a3f0..c0d1958 100644
--- a/css/tokens.css
+++ b/css/tokens.css
@@ -1384,6 +1384,28 @@ body {
.rst-detail dd, [rst-detail] dd { margin: 0; }
.rst-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: var(--rst-fs-sm); }
+/* snippet — a line of code to copy, in a box, with a Copy button that
+ copy.js shows where the browser has a clipboard.
+ The box hugs its line (fit-content) up to the width it is given, and
+ the code wraps anywhere inside it, at any width, with no media
+ query: a URL has no space to break at, and one that will not break
+ runs off a phone's screen and takes the end a reader has to copy
+ with it. border-box, so the 100% is the whole box, its padding and
+ border included. The button is spaced by its own margin, not the
+ grid's gap: a gap would stay behind when the button is hidden (no
+ script, or no clipboard) and pad the box's end twice. user-select:
+ all makes one click select the whole line, which is how it is copied
+ by hand; Safari 17.5 reads only the prefixed property.
+ The status takes a row of its own under the code when it speaks.
+ While empty it is clipped to nothing rather than display: none, so
+ the live region is in the accessibility tree before its first
+ message: one that joins the tree as it speaks may never be heard. */
+.rst-snippet, [rst-snippet] { align-items: center; background: var(--rst-surface); border: 1px solid var(--rst-line); border-radius: var(--rst-radius); box-sizing: border-box; display: grid; grid-template-columns: 1fr auto; inline-size: fit-content; max-inline-size: 100%; padding: var(--rst-sp-2) var(--rst-sp-3); position: relative; row-gap: var(--rst-sp-1); }
+.rst-snippet > code, [rst-snippet] > code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: var(--rst-fs-sm); min-inline-size: 0; overflow-wrap: anywhere; -webkit-user-select: all; user-select: all; }
+.rst-snippet__copy, [rst-snippet-copy] { margin-inline-start: var(--rst-sp-3); }
+.rst-snippet__status, [rst-snippet-status] { color: var(--rst-text-muted); font-size: var(--rst-fs-xs); grid-column: 1 / -1; }
+.rst-snippet__status:empty, [rst-snippet-status]:empty { block-size: 1px; clip-path: inset(50%); inline-size: 1px; overflow: hidden; position: absolute; white-space: nowrap; }
+
/* ── Narrow screens ───────────────────────────────────────────────── */
@media (max-width: 34rem) {
.rst-lbar > search,
diff --git a/examples/examples.json b/examples/examples.json
index 7dab11c..8f8bf98 100644
--- a/examples/examples.json
+++ b/examples/examples.json
@@ -611,6 +611,25 @@
}
]
},
+ {
+ "name": "snippet",
+ "title": "snippet",
+ "family": "display",
+ "kind": "component",
+ "document": false,
+ "wrapper": "",
+ "scripts": [
+ "copy.js"
+ ],
+ "default": "a-line-to-copy",
+ "states": [
+ {
+ "id": "a-line-to-copy",
+ "label": "A line to copy",
+ "sampleURLs": []
+ }
+ ]
+ },
{
"name": "notice",
"title": "notice",
diff --git a/examples/prose.json b/examples/prose.json
index 8411a0a..fd132a9 100644
--- a/examples/prose.json
+++ b/examples/prose.json
@@ -346,6 +346,9 @@
}
}
},
+ "snippet": {
+ "blurb": "A line of code to copy: the code in a box, and a Copy button that copy.js shows where the browser has a clipboard."
+ },
"stat": {
"blurb": "One number on a dashboard, with a label and how much it changed.",
"states": {
diff --git a/examples/snippet/a-line-to-copy.html b/examples/snippet/a-line-to-copy.html
new file mode 100644
index 0000000..e55b49d
--- /dev/null
+++ b/examples/snippet/a-line-to-copy.html
@@ -0,0 +1,18 @@
+<!doctype html>
+<html lang="en" dir="ltr">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width">
+<title>snippet: A line to copy</title>
+<link rel="stylesheet" href="../../css/tokens.css">
+<link rel="stylesheet" href="../../css/themes/day.css">
+<script defer src="../../js/copy.js"></script>
+</head>
+<body>
+<div rst-snippet>
+ <code dir="ltr" translate="no">&lt;link rel="stylesheet" href="https://fiesta.rastrillo.org/1/fiesta.css"&gt;</code>
+ <button rst-btn="sm" type="button" rst-snippet-copy hidden data-rst-copy-done="Copied" data-rst-copy-failed="Copy failed. Select the code and copy it yourself.">Copy</button>
+ <span rst-snippet-status role="status"></span>
+</div>
+</body>
+</html>
diff --git a/hack/seed/_rastrillo/prose_fiestaseed_test.go b/hack/seed/_rastrillo/prose_fiestaseed_test.go
index 1b027c7..298b85a 100644
--- a/hack/seed/_rastrillo/prose_fiestaseed_test.go
+++ b/hack/seed/_rastrillo/prose_fiestaseed_test.go
@@ -137,7 +137,7 @@ var proseFields = []string{
// Fiesta ships under that name. Any other .js in a string is a
// script Fiesta does not have.
var proseScripts = map[string]bool{
- "anchor.js": true, "busy.js": true, "calendar.js": true, "datetime.js": true,
+ "anchor.js": true, "busy.js": true, "calendar.js": true, "copy.js": true, "datetime.js": true,
"fiesta.js": true, "light.js": true, "select.js": true, "shell.js": true,
}
diff --git a/internal/gallery/heights.go b/internal/gallery/heights.go
index cb4cf1a..f521f38 100644
--- a/internal/gallery/heights.go
+++ b/internal/gallery/heights.go
@@ -184,6 +184,7 @@ var previewHeights = map[string]int{
"signin/on-the-way-to-keymail": 390,
"signin/when-keymail-could-not-confirm-it": 410,
"signin/when-keymail-was-offered-and-a-link-went-out": 390,
+ "snippet/a-line-to-copy": 110,
"stat-band/default": 190,
"stat/a-shortened-number": 140,
"stat/a-smaller-number": 140,
@@ -233,6 +234,7 @@ var previewMobileHeights = map[string]int{
"signin/on-the-way-to-keymail": 550,
"signin/when-keymail-could-not-confirm-it": 660,
"signin/when-keymail-was-offered-and-a-link-went-out": 660,
+ "snippet/a-line-to-copy": 180,
"stat-band/default": 460,
"status-pill/row": 180,
}
@@ -290,6 +292,7 @@ var previewMobileLocaleHeights = map[string]map[string]int{
"signin/on-the-way-to-keymail": 590,
"signin/when-keymail-could-not-confirm-it": 730,
"signin/when-keymail-was-offered-and-a-link-went-out": 720,
+ "snippet/a-line-to-copy": 200,
},
"ja": {
"api-tokens/four-tokens-two-of-them-live": 1900,
@@ -345,6 +348,7 @@ var previewMobileLocaleHeights = map[string]map[string]int{
"signin/on-the-way-to-keymail": 590,
"signin/when-keymail-could-not-confirm-it": 700,
"signin/when-keymail-was-offered-and-a-link-went-out": 730,
+ "snippet/a-line-to-copy": 220,
"status-pill/row": 310,
},
"vi": {
diff --git a/internal/gallery/i18n/ar.json b/internal/gallery/i18n/ar.json
index 14b36de..c61aa33 100644
--- a/internal/gallery/i18n/ar.json
+++ b/internal/gallery/i18n/ar.json
@@ -115,6 +115,7 @@
"examples.signin.states.when-keymail-was-offered-and-a-link-went-out": "سطر واحد يقول إن رابطًا أُرسل هذه المرة. ولا يخمّن السبب.",
"examples.signin.warnings.email-and-password.body": "يعيد الناس استخدامها، وتتسرّب، وترث أنت مهمة تخزينها بأمان. استخدم رابطًا في بريدك مع مفتاح مرور، أو مفاتيح المرور وحدها. الترميز هنا لأن بعض المنتجات ما زالت تحتاجه.",
"examples.signin.warnings.email-and-password.title": "لا نوصي بكلمات المرور",
+ "examples.snippet.blurb": "سطر من الشيفرة للنسخ: الشيفرة في مربع، وزر «نسخ» يُظهره ‎copy.js‎ حيث توجد في المتصفح حافظة.",
"examples.stat-band.blurb": "صف من الأرقام على امتداد أعلى لوحة المعلومات.",
"examples.stat.blurb": "رقم واحد على لوحة المعلومات، مع تسميته ومقدار تغيّره.",
"examples.stat.states.a-shortened-number": "تعرض 4.1k بدل 4120؟ ضع العدد الكامل في السمة ‎value‎ لوسم ‎<data>‎. قارئات الشاشة لا تقرأ إلا ما على الشاشة، فأبقِ كل ما يحتاجه الشخص في العدد نفسه.",
@@ -384,6 +385,7 @@
"labels.signin.on-the-way-to-keymail": "في الطريق إلى Keymail",
"labels.signin.when-keymail-could-not-confirm-it": "حين لم يستطع Keymail التأكيد",
"labels.signin.when-keymail-was-offered-and-a-link-went-out": "حين عُرض Keymail وأُرسل رابط",
+ "labels.snippet.a-line-to-copy": "سطر للنسخ",
"labels.stat.a-shortened-number": "رقم مختصر",
"labels.stat.a-smaller-number": "رقم أصغر",
"labels.stat.the-big-number": "الرقم الكبير",
diff --git a/internal/gallery/i18n/bn.json b/internal/gallery/i18n/bn.json
index 2e06c19..c545093 100644
--- a/internal/gallery/i18n/bn.json
+++ b/internal/gallery/i18n/bn.json
@@ -115,6 +115,7 @@
"examples.signin.states.when-keymail-was-offered-and-a-link-went-out": "একটি লাইন জানায় যে এবার একটি লিঙ্ক পাঠানো হয়েছে। কারণটা আন্দাজ করে না।",
"examples.signin.warnings.email-and-password.body": "মানুষ এগুলো আবার ব্যবহার করে, এগুলো ফাঁস হয়, আর নিরাপদে রাখার দায় আপনার ঘাড়ে আসে। ইমেলের একটি লিঙ্কের সঙ্গে একটি পাসকি ব্যবহার করুন, অথবা শুধু পাসকি। মার্কআপটি এখানে আছে কারণ কিছু পণ্যের এখনও এটি দরকার।",
"examples.signin.warnings.email-and-password.title": "আমরা পাসওয়ার্ড ব্যবহারের পরামর্শ দিই না",
+ "examples.snippet.blurb": "কপি করার জন্য এক লাইন কোড: একটি বাক্সে কোড, আর একটি “কপি করুন” বোতাম, যা ব্রাউজারে ক্লিপবোর্ড থাকলে copy.js দেখায়।",
"examples.stat-band.blurb": "ড্যাশবোর্ডের উপরে পরিসংখ্যানের একটি সারি।",
"examples.stat.blurb": "ড্যাশবোর্ডে একটি সংখ্যা, তার লেবেল আর কতটা বদলেছে সহ।",
"examples.stat.states.a-shortened-number": "4120-এর বদলে 4.1k দেখাচ্ছেন? পুরো সংখ্যাটি একটি <data> ট্যাগের value-তে রাখুন। স্ক্রিন রিডার কেবল স্ক্রিনে যা আছে তা-ই পড়ে, তাই মানুষের যা দরকার তা সংখ্যার মধ্যেই রাখুন।",
@@ -384,6 +385,7 @@
"labels.signin.on-the-way-to-keymail": "Keymail-এর পথে",
"labels.signin.when-keymail-could-not-confirm-it": "যখন Keymail নিশ্চিত করতে পারেনি",
"labels.signin.when-keymail-was-offered-and-a-link-went-out": "যখন Keymail দেখানো হয়েছিল আর লিঙ্ক চলে গেল",
+ "labels.snippet.a-line-to-copy": "কপি করার একটি লাইন",
"labels.stat.a-shortened-number": "সংক্ষেপে লেখা সংখ্যা",
"labels.stat.a-smaller-number": "ছোট একটি সংখ্যা",
"labels.stat.the-big-number": "বড় সংখ্যাটি",
diff --git a/internal/gallery/i18n/drafts.json b/internal/gallery/i18n/drafts.json
index 6b25f6c..5c828c1 100644
--- a/internal/gallery/i18n/drafts.json
+++ b/internal/gallery/i18n/drafts.json
@@ -815,6 +815,22 @@
"zh-Hans"
]
},
+ "examples.snippet.blurb": {
+ "english": "A line of code to copy: the code in a box, and a Copy button that copy.js shows where the browser has a clipboard.",
+ "locales": [
+ "ar",
+ "bn",
+ "es",
+ "ga",
+ "hi",
+ "ja",
+ "pt-PT",
+ "ru",
+ "vi",
+ "yue",
+ "zh-Hans"
+ ]
+ },
"examples.stat.states.a-shortened-number": {
"english": "Showing 4.1k instead of 4120? Put the full number in a <data> tag's value. Screen readers only read what is on screen, so keep anything a person needs in the number itself.",
"locales": [
@@ -1567,6 +1583,22 @@
"zh-Hans"
]
},
+ "labels.snippet.a-line-to-copy": {
+ "english": "A line to copy",
+ "locales": [
+ "ar",
+ "bn",
+ "es",
+ "ga",
+ "hi",
+ "ja",
+ "pt-PT",
+ "ru",
+ "vi",
+ "yue",
+ "zh-Hans"
+ ]
+ },
"labels.status-pill.negative": {
"english": "negative",
"locales": [
diff --git a/internal/gallery/i18n/es.json b/internal/gallery/i18n/es.json
index 5145065..019729a 100644
--- a/internal/gallery/i18n/es.json
+++ b/internal/gallery/i18n/es.json
@@ -115,6 +115,7 @@
"examples.signin.states.when-keymail-was-offered-and-a-link-went-out": "Una línea dice que esta vez se envió un enlace. No adivina por qué.",
"examples.signin.warnings.email-and-password.body": "La gente las reutiliza, se filtran, y a ti te toca el trabajo de guardarlas de forma segura. Usa un enlace por correo junto con una clave de acceso, o solo claves de acceso. El marcado está aquí porque algunos productos todavía lo necesitan.",
"examples.signin.warnings.email-and-password.title": "No recomendamos las contraseñas",
+ "examples.snippet.blurb": "Una línea de código para copiar: el código en un recuadro y un botón Copiar que copy.js muestra donde el navegador tiene portapapeles.",
"examples.stat-band.blurb": "Una fila de cifras a lo largo de la parte superior de un panel.",
"examples.stat.blurb": "Un número en un panel, con una etiqueta y cuánto ha cambiado.",
"examples.stat.states.a-shortened-number": "¿Muestras 4.1k en lugar de 4120? Pon el número completo en el value de una etiqueta <data>. Los lectores de pantalla solo leen lo que está en pantalla, así que deja en el propio número todo lo que una persona necesite.",
@@ -384,6 +385,7 @@
"labels.signin.on-the-way-to-keymail": "De camino a Keymail",
"labels.signin.when-keymail-could-not-confirm-it": "Cuando Keymail no pudo confirmarlo",
"labels.signin.when-keymail-was-offered-and-a-link-went-out": "Cuando se ofreció Keymail y salió un enlace",
+ "labels.snippet.a-line-to-copy": "Una línea para copiar",
"labels.stat.a-shortened-number": "Un número acortado",
"labels.stat.a-smaller-number": "Un número más pequeño",
"labels.stat.the-big-number": "El número grande",
diff --git a/internal/gallery/i18n/ga.json b/internal/gallery/i18n/ga.json
index 06dc146..55db025 100644
--- a/internal/gallery/i18n/ga.json
+++ b/internal/gallery/i18n/ga.json
@@ -115,6 +115,7 @@
"examples.signin.states.when-keymail-was-offered-and-a-link-went-out": "Deir líne amháin gur seoladh nasc an uair seo. Ní thugann sí buille faoi thuairim cén fáth.",
"examples.signin.warnings.email-and-password.body": "Athúsáideann daoine iad, sceitheann siad, agus is ortsa a thiteann sé iad a stóráil go sábháilte. Úsáid nasc i do ríomhphost móide eochair phasfhocail, nó eochracha pasfhocail ina n-aonar. Tá an mharcáil anseo mar go bhfuil sí fós ag teastáil ó roinnt táirgí.",
"examples.signin.warnings.email-and-password.title": "Ní mholaimid pasfhocail",
+ "examples.snippet.blurb": "Líne chóid le cóipeáil: an cód i mbosca, agus cnaipe Cóipeáil a thaispeánann copy.js áit a bhfuil gearrthaisce ag an mbrabhsálaí.",
"examples.stat-band.blurb": "Sraith staitisticí trasna barr painéil.",
"examples.stat.blurb": "Uimhir amháin ar phainéal, le lipéad agus a mhéid a d'athraigh sí.",
"examples.stat.states.a-shortened-number": "Ag taispeáint 4.1k in ionad 4120? Cuir an uimhir iomlán i value clib <data>. Ní léann léitheoirí scáileáin ach a bhfuil ar an scáileán, mar sin coinnigh aon rud a theastaíonn ó dhuine san uimhir féin.",
@@ -384,6 +385,7 @@
"labels.signin.on-the-way-to-keymail": "Ar an mbealach go Keymail",
"labels.signin.when-keymail-could-not-confirm-it": "Nuair nach raibh Keymail in ann é a dheimhniú",
"labels.signin.when-keymail-was-offered-and-a-link-went-out": "Nuair a tairgeadh Keymail agus gur seoladh nasc",
+ "labels.snippet.a-line-to-copy": "Líne le cóipeáil",
"labels.stat.a-shortened-number": "Uimhir ghiorraithe",
"labels.stat.a-smaller-number": "Uimhir níos lú",
"labels.stat.the-big-number": "An uimhir mhór",
diff --git a/internal/gallery/i18n/hi.json b/internal/gallery/i18n/hi.json
index b0de9a0..e78f6ee 100644
--- a/internal/gallery/i18n/hi.json
+++ b/internal/gallery/i18n/hi.json
@@ -115,6 +115,7 @@
"examples.signin.states.when-keymail-was-offered-and-a-link-went-out": "एक पंक्ति बताती है कि इस बार लिंक भेजा गया। वह कारण का अनुमान नहीं लगाती।",
"examples.signin.warnings.email-and-password.body": "लोग इन्हें दोबारा इस्तेमाल करते हैं, ये लीक होते हैं, और इन्हें सुरक्षित रखने का काम आपके सिर आता है। ईमेल में एक लिंक के साथ पासकी इस्तेमाल करें, या सिर्फ़ पासकी। मार्कअप यहाँ इसलिए है क्योंकि कुछ उत्पादों को अब भी इसकी ज़रूरत है।",
"examples.signin.warnings.email-and-password.title": "हम पासवर्ड की सलाह नहीं देते",
+ "examples.snippet.blurb": "कॉपी करने के लिए कोड की एक पंक्ति: एक बॉक्स में कोड, और एक “कॉपी करें” बटन, जिसे copy.js वहाँ दिखाता है जहाँ ब्राउज़र में क्लिपबोर्ड है।",
"examples.stat-band.blurb": "डैशबोर्ड के ऊपर आँकड़ों की एक पंक्ति।",
"examples.stat.blurb": "डैशबोर्ड पर एक संख्या, उसके लेबल और उसमें कितना बदलाव हुआ, के साथ।",
"examples.stat.states.a-shortened-number": "4120 की जगह 4.1k दिखा रहे हैं? पूरी संख्या को <data> टैग के value में रखें। स्क्रीन रीडर सिर्फ़ वही पढ़ते हैं जो स्क्रीन पर है, इसलिए किसी को जो चाहिए वह संख्या में ही रखें।",
@@ -384,6 +385,7 @@
"labels.signin.on-the-way-to-keymail": "Keymail की ओर जाते हुए",
"labels.signin.when-keymail-could-not-confirm-it": "जब Keymail पुष्टि नहीं कर सका",
"labels.signin.when-keymail-was-offered-and-a-link-went-out": "जब Keymail पेश किया गया और लिंक चला गया",
+ "labels.snippet.a-line-to-copy": "कॉपी करने के लिए एक पंक्ति",
"labels.stat.a-shortened-number": "छोटा करके लिखी संख्या",
"labels.stat.a-smaller-number": "छोटी संख्या",
"labels.stat.the-big-number": "बड़ी संख्या",
diff --git a/internal/gallery/i18n/ja.json b/internal/gallery/i18n/ja.json
index c75a9ae..72d2276 100644
--- a/internal/gallery/i18n/ja.json
+++ b/internal/gallery/i18n/ja.json
@@ -115,6 +115,7 @@
"examples.signin.states.when-keymail-was-offered-and-a-link-went-out": "今回はリンクを送ったと一行で伝えます。理由は推測しません。",
"examples.signin.warnings.email-and-password.body": "パスワードは使い回され、漏れ、そして安全に保管する仕事はあなたに回ってきます。メールのリンクとパスキーの組み合わせか、パスキーだけを使ってください。マークアップがここにあるのは、まだ必要とする製品があるからです。",
"examples.signin.warnings.email-and-password.title": "パスワードはおすすめしません",
+ "examples.snippet.blurb": "コピーするための1行のコード。コードを枠に収め、ブラウザにクリップボードがあれば copy.js が「コピー」ボタンを表示します。",
"examples.stat-band.blurb": "ダッシュボードの上部に並ぶ数値の行です。",
"examples.stat.blurb": "ダッシュボードのひとつの数値です。ラベルと、どれだけ変わったかが付きます。",
"examples.stat.states.a-shortened-number": "4120 ではなく 4.1k と表示していますか。完全な数値は <data> タグの value に入れてください。スクリーンリーダーは画面にあるものしか読まないので、人に必要なものは数値そのものに残してください。",
@@ -384,6 +385,7 @@
"labels.signin.on-the-way-to-keymail": "Keymail へ移動中",
"labels.signin.when-keymail-could-not-confirm-it": "Keymail で確認できなかったとき",
"labels.signin.when-keymail-was-offered-and-a-link-went-out": "Keymail を示したのにリンクが送られたとき",
+ "labels.snippet.a-line-to-copy": "コピーする1行",
"labels.stat.a-shortened-number": "短くした数値",
"labels.stat.a-smaller-number": "小さい数値",
"labels.stat.the-big-number": "大きい数値",
diff --git a/internal/gallery/i18n/pt-PT.json b/internal/gallery/i18n/pt-PT.json
index 07752dc..7a6bf3e 100644
--- a/internal/gallery/i18n/pt-PT.json
+++ b/internal/gallery/i18n/pt-PT.json
@@ -115,6 +115,7 @@
"examples.signin.states.when-keymail-was-offered-and-a-link-went-out": "Uma linha diz que desta vez foi enviada uma ligação. Não tenta adivinhar porquê.",
"examples.signin.warnings.email-and-password.body": "As pessoas reutilizam-nas, elas vazam, e herda o trabalho de as guardar em segurança. Use uma ligação por e-mail mais uma chave de acesso, ou só chaves de acesso. A marcação está aqui porque alguns produtos ainda precisam dela.",
"examples.signin.warnings.email-and-password.title": "Não recomendamos palavras-passe",
+ "examples.snippet.blurb": "Uma linha de código para copiar: o código numa caixa e um botão Copiar que o copy.js mostra onde o navegador tem área de transferência.",
"examples.stat-band.blurb": "Uma linha de números ao longo do topo de um painel.",
"examples.stat.blurb": "Um número num painel, com uma etiqueta e quanto mudou.",
"examples.stat.states.a-shortened-number": "Mostra 4.1k em vez de 4120? Ponha o número completo no value de uma etiqueta <data>. Os leitores de ecrã só leem o que está no ecrã, por isso deixe no próprio número tudo o que uma pessoa precise.",
@@ -384,6 +385,7 @@
"labels.signin.on-the-way-to-keymail": "A caminho do Keymail",
"labels.signin.when-keymail-could-not-confirm-it": "Quando o Keymail não conseguiu confirmar",
"labels.signin.when-keymail-was-offered-and-a-link-went-out": "Quando foi oferecido o Keymail e seguiu uma ligação",
+ "labels.snippet.a-line-to-copy": "Uma linha para copiar",
"labels.stat.a-shortened-number": "Um número encurtado",
"labels.stat.a-smaller-number": "Um número mais pequeno",
"labels.stat.the-big-number": "O número grande",
diff --git a/internal/gallery/i18n/ru.json b/internal/gallery/i18n/ru.json
index d34d4f4..e8f3d8b 100644
--- a/internal/gallery/i18n/ru.json
+++ b/internal/gallery/i18n/ru.json
@@ -115,6 +115,7 @@
"examples.signin.states.when-keymail-was-offered-and-a-link-went-out": "Одна строка сообщает, что на этот раз отправлена ссылка. Причину она не угадывает.",
"examples.signin.warnings.email-and-password.body": "Люди используют их повторно, они утекают, а хранить их безопасно приходится вам. Используйте ссылку в письме вместе с ключом доступа или только ключи доступа. Разметка здесь потому, что некоторым продуктам она всё ещё нужна.",
"examples.signin.warnings.email-and-password.title": "Мы не рекомендуем пароли",
+ "examples.snippet.blurb": "Строка кода для копирования: код в рамке и кнопка «Копировать», которую copy.js показывает там, где у браузера есть буфер обмена.",
"examples.stat-band.blurb": "Ряд показателей вдоль верха панели.",
"examples.stat.blurb": "Одно число на панели, с подписью и тем, насколько оно изменилось.",
"examples.stat.states.a-shortened-number": "Показываете 4.1k вместо 4120? Поместите полное число в value тега <data>. Экранные дикторы читают только то, что на экране, поэтому всё, что нужно человеку, оставляйте в самом числе.",
@@ -384,6 +385,7 @@
"labels.signin.on-the-way-to-keymail": "По пути в Keymail",
"labels.signin.when-keymail-could-not-confirm-it": "Когда Keymail не смог подтвердить",
"labels.signin.when-keymail-was-offered-and-a-link-went-out": "Когда предлагался Keymail, а ушла ссылка",
+ "labels.snippet.a-line-to-copy": "Строка для копирования",
"labels.stat.a-shortened-number": "Сокращённое число",
"labels.stat.a-smaller-number": "Число поменьше",
"labels.stat.the-big-number": "Большое число",
diff --git a/internal/gallery/i18n/samples.ar.json b/internal/gallery/i18n/samples.ar.json
index 322dfd9..69606f8 100644
--- a/internal/gallery/i18n/samples.ar.json
+++ b/internal/gallery/i18n/samples.ar.json
@@ -85,6 +85,7 @@
"Continue to Keymail": "المتابعة إلى Keymail",
"Continue with Apple": "المتابعة عبر Apple",
"Continue with Google": "المتابعة عبر Google",
+ "Copy": "نسخ",
"Copy this token now. It won&#39;t be shown again.": "انسخ هذا الرمز الآن. لن يُعرض مرة أخرى.",
"Create token": "إنشاء رمز",
"Created": "تاريخ الإنشاء",
diff --git a/internal/gallery/i18n/samples.bn.json b/internal/gallery/i18n/samples.bn.json
index 1f59de2..1359301 100644
--- a/internal/gallery/i18n/samples.bn.json
+++ b/internal/gallery/i18n/samples.bn.json
@@ -85,6 +85,7 @@
"Continue to Keymail": "Keymail-এ চালিয়ে যান",
"Continue with Apple": "Apple দিয়ে চালিয়ে যান",
"Continue with Google": "Google দিয়ে চালিয়ে যান",
+ "Copy": "কপি করুন",
"Copy this token now. It won&#39;t be shown again.": "এই টোকেনটি এখনই কপি করুন। এটি আর দেখানো হবে না।",
"Create token": "টোকেন তৈরি করুন",
"Created": "তৈরি হয়েছে",
diff --git a/internal/gallery/i18n/samples.es.json b/internal/gallery/i18n/samples.es.json
index 67af1fd..a480608 100644
--- a/internal/gallery/i18n/samples.es.json
+++ b/internal/gallery/i18n/samples.es.json
@@ -85,6 +85,7 @@
"Continue to Keymail": "Continuar con Keymail",
"Continue with Apple": "Continuar con Apple",
"Continue with Google": "Continuar con Google",
+ "Copy": "Copiar",
"Copy this token now. It won&#39;t be shown again.": "Copia este token ahora. No se volverá a mostrar.",
"Create token": "Crear token",
"Created": "Creado",
diff --git a/internal/gallery/i18n/samples.ga.json b/internal/gallery/i18n/samples.ga.json
index 041118f..1526c56 100644
--- a/internal/gallery/i18n/samples.ga.json
+++ b/internal/gallery/i18n/samples.ga.json
@@ -85,6 +85,7 @@
"Continue to Keymail": "Lean ar aghaidh chuig Keymail",
"Continue with Apple": "Lean ar aghaidh le Apple",
"Continue with Google": "Lean ar aghaidh le Google",
+ "Copy": "Cóipeáil",
"Copy this token now. It won&#39;t be shown again.": "Cóipeáil an comhartha seo anois. Ní thaispeánfar arís é.",
"Create token": "Cruthaigh comhartha",
"Created": "Cruthaithe",
diff --git a/internal/gallery/i18n/samples.hi.json b/internal/gallery/i18n/samples.hi.json
index 96e57fb..ae00df1 100644
--- a/internal/gallery/i18n/samples.hi.json
+++ b/internal/gallery/i18n/samples.hi.json
@@ -85,6 +85,7 @@
"Continue to Keymail": "Keymail पर जारी रखें",
"Continue with Apple": "Apple से जारी रखें",
"Continue with Google": "Google से जारी रखें",
+ "Copy": "कॉपी करें",
"Copy this token now. It won&#39;t be shown again.": "इस टोकन को अभी कॉपी करें। यह दोबारा नहीं दिखाया जाएगा।",
"Create token": "टोकन बनाएँ",
"Created": "बनाया गया",
diff --git a/internal/gallery/i18n/samples.ja.json b/internal/gallery/i18n/samples.ja.json
index 5c0d872..222747f 100644
--- a/internal/gallery/i18n/samples.ja.json
+++ b/internal/gallery/i18n/samples.ja.json
@@ -85,6 +85,7 @@
"Continue to Keymail": "Keymail へ進む",
"Continue with Apple": "Apple で続ける",
"Continue with Google": "Google で続ける",
+ "Copy": "コピー",
"Copy this token now. It won&#39;t be shown again.": "このトークンを今すぐコピーしてください。二度と表示されません。",
"Create token": "トークンを作成",
"Created": "作成日",
diff --git a/internal/gallery/i18n/samples.pt-PT.json b/internal/gallery/i18n/samples.pt-PT.json
index 057dfd2..cc91db8 100644
--- a/internal/gallery/i18n/samples.pt-PT.json
+++ b/internal/gallery/i18n/samples.pt-PT.json
@@ -85,6 +85,7 @@
"Continue to Keymail": "Continuar para o Keymail",
"Continue with Apple": "Continuar com Apple",
"Continue with Google": "Continuar com Google",
+ "Copy": "Copiar",
"Copy this token now. It won&#39;t be shown again.": "Copie este token agora. Não voltará a ser mostrado.",
"Create token": "Criar token",
"Created": "Criado",
diff --git a/internal/gallery/i18n/samples.ru.json b/internal/gallery/i18n/samples.ru.json
index 5ed471c..ce452f4 100644
--- a/internal/gallery/i18n/samples.ru.json
+++ b/internal/gallery/i18n/samples.ru.json
@@ -85,6 +85,7 @@
"Continue to Keymail": "Продолжить в Keymail",
"Continue with Apple": "Продолжить с Apple",
"Continue with Google": "Продолжить с Google",
+ "Copy": "Копировать",
"Copy this token now. It won&#39;t be shown again.": "Скопируйте этот токен сейчас. Больше он не будет показан.",
"Create token": "Создать токен",
"Created": "Создан",
diff --git a/internal/gallery/i18n/samples.vi.json b/internal/gallery/i18n/samples.vi.json
index a629f56..f9a18e3 100644
--- a/internal/gallery/i18n/samples.vi.json
+++ b/internal/gallery/i18n/samples.vi.json
@@ -85,6 +85,7 @@
"Continue to Keymail": "Tiếp tục đến Keymail",
"Continue with Apple": "Tiếp tục với Apple",
"Continue with Google": "Tiếp tục với Google",
+ "Copy": "Sao chép",
"Copy this token now. It won&#39;t be shown again.": "Hãy sao chép mã này ngay. Nó sẽ không hiển thị lại.",
"Create token": "Tạo mã thông báo",
"Created": "Đã tạo",
diff --git a/internal/gallery/i18n/samples.yue.json b/internal/gallery/i18n/samples.yue.json
index fb784ed..97c893f 100644
--- a/internal/gallery/i18n/samples.yue.json
+++ b/internal/gallery/i18n/samples.yue.json
@@ -85,6 +85,7 @@
"Continue to Keymail": "繼續去 Keymail",
"Continue with Apple": "用 Apple 繼續",
"Continue with Google": "用 Google 繼續",
+ "Copy": "複製",
"Copy this token now. It won&#39;t be shown again.": "而家就複製呢個權杖。之後唔會再顯示。",
"Create token": "建立權杖",
"Created": "建立時間",
diff --git a/internal/gallery/i18n/samples.zh-Hans.json b/internal/gallery/i18n/samples.zh-Hans.json
index e81d782..fc0cf50 100644
--- a/internal/gallery/i18n/samples.zh-Hans.json
+++ b/internal/gallery/i18n/samples.zh-Hans.json
@@ -85,6 +85,7 @@
"Continue to Keymail": "继续前往 Keymail",
"Continue with Apple": "使用 Apple 继续",
"Continue with Google": "使用 Google 继续",
+ "Copy": "复制",
"Copy this token now. It won&#39;t be shown again.": "现在就复制这个令牌。它不会再显示。",
"Create token": "创建令牌",
"Created": "创建时间",
diff --git a/internal/gallery/i18n/vi.json b/internal/gallery/i18n/vi.json
index 792cb34..2945ef8 100644
--- a/internal/gallery/i18n/vi.json
+++ b/internal/gallery/i18n/vi.json
@@ -115,6 +115,7 @@
"examples.signin.states.when-keymail-was-offered-and-a-link-went-out": "Một dòng cho biết lần này đã gửi liên kết. Nó không đoán lý do.",
"examples.signin.warnings.email-and-password.body": "Người ta dùng lại chúng, chúng bị lộ, và bạn phải gánh việc lưu trữ chúng an toàn. Hãy dùng một liên kết trong email kèm khóa truy cập, hoặc chỉ khóa truy cập. Mã đánh dấu có ở đây vì một số sản phẩm vẫn cần nó.",
"examples.signin.warnings.email-and-password.title": "Chúng tôi không khuyến nghị mật khẩu",
+ "examples.snippet.blurb": "Một dòng mã để sao chép: mã nằm trong một khung, và nút Sao chép mà copy.js hiển thị ở trình duyệt có bộ nhớ tạm.",
"examples.stat-band.blurb": "Một hàng chỉ số chạy ngang phía trên bảng điều khiển.",
"examples.stat.blurb": "Một con số trên bảng điều khiển, kèm nhãn và mức thay đổi.",
"examples.stat.states.a-shortened-number": "Hiển thị 4.1k thay vì 4120? Đặt số đầy đủ vào value của thẻ <data>. Trình đọc màn hình chỉ đọc những gì có trên màn hình, nên hãy giữ mọi thứ người dùng cần ngay trong con số.",
@@ -384,6 +385,7 @@
"labels.signin.on-the-way-to-keymail": "Trên đường tới Keymail",
"labels.signin.when-keymail-could-not-confirm-it": "Khi Keymail không xác nhận được",
"labels.signin.when-keymail-was-offered-and-a-link-went-out": "Khi Keymail được đề xuất nhưng một liên kết được gửi đi",
+ "labels.snippet.a-line-to-copy": "Một dòng để sao chép",
"labels.stat.a-shortened-number": "Một con số rút gọn",
"labels.stat.a-smaller-number": "Một con số nhỏ hơn",
"labels.stat.the-big-number": "Con số lớn",
diff --git a/internal/gallery/i18n/yue.json b/internal/gallery/i18n/yue.json
index 3c6d5e1..9c3eac4 100644
--- a/internal/gallery/i18n/yue.json
+++ b/internal/gallery/i18n/yue.json
@@ -115,6 +115,7 @@
"examples.signin.states.when-keymail-was-offered-and-a-link-went-out": "用一句話講今次寄咗連結,唔會估原因。",
"examples.signin.warnings.email-and-password.body": "人哋會重用密碼,密碼會外洩,而安全咁儲存佢哋嘅責任就落咗喺你度。用電郵入面嘅連結加通行密鑰,或者淨係用通行密鑰。標記擺喺度,係因為有啲產品仲需要佢。",
"examples.signin.warnings.email-and-password.title": "我哋唔建議用密碼",
+ "examples.snippet.blurb": "一行要複製嘅代碼:代碼放喺框入面,加一個「複製」掣,瀏覽器有剪貼簿嘅話 copy.js 就會顯示佢。",
"examples.stat-band.blurb": "儀表板頂部一行數字。",
"examples.stat.blurb": "儀表板上嘅一個數字,有標籤,同埋變咗幾多。",
"examples.stat.states.a-shortened-number": "顯示 4.1k 而唔係 4120?將完整數字放入 <data> 標籤嘅 value。螢幕閱讀器只會讀畫面上嘅嘢,所以人需要嘅嘢都要留喺個數字本身。",
@@ -384,6 +385,7 @@
"labels.signin.on-the-way-to-keymail": "去緊 Keymail",
"labels.signin.when-keymail-could-not-confirm-it": "Keymail 確認唔到嘅時候",
"labels.signin.when-keymail-was-offered-and-a-link-went-out": "提供嘅係 Keymail,寄出去嘅卻係連結",
+ "labels.snippet.a-line-to-copy": "要複製嘅一行",
"labels.stat.a-shortened-number": "縮短咗嘅數字",
"labels.stat.a-smaller-number": "細啲嘅數字",
"labels.stat.the-big-number": "個大數字",
diff --git a/internal/gallery/i18n/zh-Hans.json b/internal/gallery/i18n/zh-Hans.json
index 5b66eea..2ba1037 100644
--- a/internal/gallery/i18n/zh-Hans.json
+++ b/internal/gallery/i18n/zh-Hans.json
@@ -115,6 +115,7 @@
"examples.signin.states.when-keymail-was-offered-and-a-link-went-out": "用一句话说明这次寄的是链接,不去猜原因。",
"examples.signin.warnings.email-and-password.body": "人们会重复使用密码,密码会泄露,而安全保存它们的活儿就落到了你头上。用邮件里的链接加通行密钥,或者只用通行密钥。标记放在这里,是因为有些产品还需要它。",
"examples.signin.warnings.email-and-password.title": "我们不建议使用密码",
+ "examples.snippet.blurb": "一行要复制的代码:代码放在框中,另有一个“复制”按钮,浏览器有剪贴板时由 copy.js 显示。",
"examples.stat-band.blurb": "仪表板顶部的一排数字。",
"examples.stat.blurb": "仪表板上的一个数字,带标签,以及它变化了多少。",
"examples.stat.states.a-shortened-number": "显示 4.1k 而不是 4120?把完整数字放进 <data> 标签的 value。屏幕阅读器只读屏幕上的内容,所以人需要的东西都要留在数字本身里。",
@@ -384,6 +385,7 @@
"labels.signin.on-the-way-to-keymail": "正在前往 Keymail",
"labels.signin.when-keymail-could-not-confirm-it": "Keymail 无法确认时",
"labels.signin.when-keymail-was-offered-and-a-link-went-out": "提供的是 Keymail,寄出的却是链接",
+ "labels.snippet.a-line-to-copy": "要复制的一行",
"labels.stat.a-shortened-number": "缩短过的数字",
"labels.stat.a-smaller-number": "较小的数字",
"labels.stat.the-big-number": "那个大数字",
diff --git a/internal/gallery/localise_test.go b/internal/gallery/localise_test.go
index 44eb1ff..2f935b1 100644
--- a/internal/gallery/localise_test.go
+++ b/internal/gallery/localise_test.go
@@ -372,14 +372,14 @@ func TestEverySampleStringIsTranslated(t *testing.T) {
}
}
-// controlAttrsOf is every data-rst-select-* and data-rst-date-* value
-// in a document, by its key.
+// controlAttrsOf is every data-rst-select-*, data-rst-date-* and
+// data-rst-copy-* value in a document, by its key.
func controlAttrsOf(t *testing.T, doc []byte) map[string]string {
t.Helper()
out := map[string]string{}
attrsOf(t, doc, func(_ string, attrs map[string]string) {
for k, v := range attrs {
- if key, ok := strings.CutPrefix(k, "data-rst-"); ok && (strings.HasPrefix(key, "select-") || strings.HasPrefix(key, "date-")) {
+ if key, ok := strings.CutPrefix(k, "data-rst-"); ok && (strings.HasPrefix(key, "select-") || strings.HasPrefix(key, "date-") || strings.HasPrefix(key, "copy-")) {
out[key] = v
}
}
@@ -387,10 +387,13 @@ func controlAttrsOf(t *testing.T, doc []byte) map[string]string {
return out
}
-// TestTheEnhancedControlsCarryThePagesStrings: in day/ja's field-select
-// and field-date frames, every data-rst-select-* and data-rst-date-*
-// value is the framework's own ja catalog's, the date words as their
-// JSON; English's are the seeded values.
+// TestTheEnhancedControlsCarryThePagesStrings: in day/ja's field-select,
+// field-date and snippet frames, every data-rst-select-*,
+// data-rst-date-* and data-rst-copy-* value is the framework's own ja
+// catalog's, the date words as their JSON; English's are the seeded
+// values. The snippet's button must carry both of its strings: the
+// attribute wins over the page's table, so an English one left on it
+// would say "Copied" on a Japanese page.
func TestTheEnhancedControlsCarryThePagesStrings(t *testing.T) {
files := render(t, "")
b, ok := fiesta.Strings("ja")
@@ -402,14 +405,22 @@ func TestTheEnhancedControlsCarryThePagesStrings(t *testing.T) {
t.Fatal(err)
}
read := 0
- for _, name := range []string{"field-select", "field-date"} {
+ for _, name := range []string{"field-select", "field-date", "snippet"} {
e := mustEntry(t, name)
for _, st := range e.States {
doc := files["day/ja/frames/"+name+"/"+st.ID+".html"]
if doc == nil {
t.Fatalf("no day/ja/frames/%s/%s.html", name, st.ID)
}
- for key, got := range controlAttrsOf(t, doc) {
+ attrs := controlAttrsOf(t, doc)
+ if name == "snippet" {
+ for _, key := range []string{"copy-done", "copy-failed"} {
+ if _, ok := attrs[key]; !ok {
+ t.Errorf("snippet/%s carries no data-rst-%s on its Copy button", st.ID, key)
+ }
+ }
+ }
+ for key, got := range attrs {
read++
want, ok := raw[key]
if !ok {
diff --git a/internal/gallery/nav.go b/internal/gallery/nav.go
index bdb7deb..abde28f 100644
--- a/internal/gallery/nav.go
+++ b/internal/gallery/nav.go
@@ -43,6 +43,7 @@ var searchTerms = map[string]string{
"box": "card panel",
"person": "avatar user",
"button": "button submit cta",
+ "snippet": "code copy clipboard",
}
// shellNames are the shells the Demos group opens, in the order the
diff --git a/internal/gallery/prose_rule_test.go b/internal/gallery/prose_rule_test.go
index fb0183a..872e175 100644
--- a/internal/gallery/prose_rule_test.go
+++ b/internal/gallery/prose_rule_test.go
@@ -49,7 +49,7 @@ var proseFields = []string{
// proseScripts are the script files a string may name: the ones
// Fiesta ships under that name.
var proseScripts = map[string]bool{
- "anchor.js": true, "busy.js": true, "calendar.js": true, "datetime.js": true,
+ "anchor.js": true, "busy.js": true, "calendar.js": true, "copy.js": true, "datetime.js": true,
"fiesta.js": true, "light.js": true, "select.js": true, "shell.js": true,
}