rastrillo / fiesta Public

The design system explains each family, component and state

Human

Prompt

Task 2 of Fiesta part 3, phase B: apply the 55 strings copy review approved unchanged, render the prose in the gallery, axe the gallery pages, and keep the untagged release's dist unchanged.

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

The exporter's approvedProse table now holds the 54 approved replacements, so a reseed writes them and never the rastrillo text; examples/prose.json and hack/seed/prose-review.json are its output, run twice and byte-identical, with their hashes and the run-prose.sh sentence in hack/seed/README.md. Render reads the prose: each family's title and sentence head its section on the index and sit above each of its example pages' headings, an example's sentence goes under its title in rst-page-header-sub, a state's note goes under its label, and the two idiom rules are info callouts at the head of the primitives. The family headings come from the prose now, so the gallery.family.* strings in copy.go are gone; Primitives reads UI primitives and Formats reads Dates, numbers and names. TestTheGalleryPagesPassAxe scans the index, tokens, icons, button, dropdown, signin and format-money pages in every theme, light and dark, plus the right-to-left index in day.
Agent notes
The ten prose tests were red against the Render that ignored prose and are green now; TestProseNamesOnlyRealExamplesAndStates and TestNoProseTeachesRastrillo already passed on the file alone and carry their own controls. TestTheGalleryPagesPassAxe was proved live by a text input with no label planted in indexTemplate, which it reported as label on every index scan, then removed. TestAnUntaggedReleaseIsAFreshDist skips, since v1.0.0 is tagged; TestPublishedReleasesMatchTheirTags holds the release to its tag and is green, and build.Dist reads nothing under examples/, so prose.json is in fiesta.FS() and not in the dist. The one-line introduction above the two new hash lines in hack/seed/README.md did not go through copy review.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 2e238dc4703469b952f98f62ce1ba932e5de68ad parent 6ebcf2c
9 files changed, +977 −29
  • examples/prose.json +378 −0
  • hack/seed/README.md +7 −0
  • hack/seed/_rastrillo/prose_fiestaseed_test.go +56 −1
  • hack/seed/prose-review.json +326 −0
  • internal/gallery/copy.go +4 −13
  • internal/gallery/gallery.css +5 −0
  • internal/gallery/gallery.go +87 −13
  • internal/gallery/pages_a11y_browser_test.go +112 −0
  • internal/gallery/prose.go +2 −2

CI passed — run details

CI log
=== gofmt ===
=== no-rastrillo ===
go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 .
ok  	amadan.net/rastrillo/fiesta	0.097s
=== size ===
go test ./internal/build -run '^TestTheOneLinkIsUnder25KiBGzipped$' -count=1 -v
=== RUN   TestTheOneLinkIsUnder25KiBGzipped
    build_test.go:138: fiesta-plain.css: 152856 bytes, 22303 bytes gzipped (21.8 KiB of the 25.0 KiB limit)
    build_test.go:138: fiesta-signal.css: 152951 bytes, 22414 bytes gzipped (21.9 KiB of the 25.0 KiB limit)
    build_test.go:138: fiesta.css: 155770 bytes, 23018 bytes gzipped (22.5 KiB of the 25.0 KiB limit)
--- PASS: TestTheOneLinkIsUnder25KiBGzipped (0.02s)
PASS
ok  	amadan.net/rastrillo/fiesta/internal/build	0.021s
=== suite ===
--- shard 0 of 12 ---
==> shard 0 of 12: make root
go build ./...
go vet ./...
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 go test -count=1 ./...
ok  	amadan.net/rastrillo/fiesta	1.299s
ok  	amadan.net/rastrillo/fiesta/check	5.282s
ok  	amadan.net/rastrillo/fiesta/cmd/fiesta	6.387s
ok  	amadan.net/rastrillo/fiesta/example	0.199s
ok  	amadan.net/rastrillo/fiesta/internal/axe	0.156s
ok  	amadan.net/rastrillo/fiesta/internal/build	2.047s
ok  	amadan.net/rastrillo/fiesta/internal/codeview	0.017s
ok  	amadan.net/rastrillo/fiesta/internal/gallery	5.527s
?   	amadan.net/rastrillo/fiesta/internal/harness	[no test files]
ok  	amadan.net/rastrillo/fiesta/internal/markup	0.012s
ok  	amadan.net/rastrillo/fiesta/internal/nodetest	0.503s
ok  	amadan.net/rastrillo/fiesta/internal/site	3.710s
ok  	amadan.net/rastrillo/fiesta/internal/tokens	0.030s
ok  	amadan.net/rastrillo/fiesta/internal/www	3.012s
--- shard 1 of 12 ---
==> shard 1 of 12: make staticcheck
go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 -tags browser ./...
--- shard 2 of 12 ---
==> shard 2 of 12: 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.
--- shard 3 of 12 ---
==> shard 3 of 12: make gitleaks
go run github.com/zricethezav/gitleaks/v8@v8.30.1 git --no-banner --redact --log-opts='--full-history --diff-filter=tuxdb HEAD' .
2:16PM INF Unknown SCM platform. Use --platform to include links in findings. host=amadan.net
2:16PM INF 1 commits scanned.
2:16PM INF scanned ~5586450 bytes (5.59 MB) in 3.58s
2:16PM INF no leaks found
--- shard 4 of 12 ---
==> shard 4 of 12: make browser.internal.harness
./hack/gotest.sh browser 'internal.harness'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
ok  	amadan.net/rastrillo/fiesta/internal/harness	10.781s
--- shard 5 of 12 ---
==> shard 5 of 12: make browser.check~1of3
./hack/gotest.sh browser 'check~1of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 3 of ./check/ runs 95 of 286 tests
ok  	amadan.net/rastrillo/fiesta/check	187.520s
--- shard 6 of 12 ---
==> shard 6 of 12: make browser.check~2of3
./hack/gotest.sh browser 'check~2of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 3 of ./check/ runs 95 of 286 tests
ok  	amadan.net/rastrillo/fiesta/check	198.379s
--- shard 7 of 12 ---
==> shard 7 of 12: make browser.check~3of3
./hack/gotest.sh browser 'check~3of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 3 of 3 of ./check/ runs 96 of 286 tests
ok  	amadan.net/rastrillo/fiesta/check	196.399s
--- shard 8 of 12 ---
==> shard 8 of 12: make browser.internal.gallery~1of2
./hack/gotest.sh browser 'internal.gallery~1of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 2 of ./internal/gallery/ runs 1 of 24 tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	70.493s
--- shard 9 of 12 ---
==> shard 9 of 12: make browser.internal.gallery~2of2
./hack/gotest.sh browser 'internal.gallery~2of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 2 of ./internal/gallery/ runs 23 of 24 tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	30.993s
--- shard 10 of 12 ---
==> shard 10 of 12: make browser.internal.www~1of2
./hack/gotest.sh browser 'internal.www~1of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 2 of ./internal/www/ runs 3 of 5 tests
ok  	amadan.net/rastrillo/fiesta/internal/www	1.736s
--- shard 11 of 12 ---
==> shard 11 of 12: make browser.internal.www~2of2
./hack/gotest.sh browser 'internal.www~2of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 2 of ./internal/www/ runs 2 of 5 tests
ok  	amadan.net/rastrillo/fiesta/internal/www	0.905s
diff --git a/examples/prose.json b/examples/prose.json
new file mode 100644
index 0000000..7fa482a
--- /dev/null
+++ b/examples/prose.json
@@ -0,0 +1,378 @@
+{
+ "families": {
+ "choices-and-actions": {
+ "title": "Choices and actions",
+ "blurb": "The controls that pick from options, and the rows that end a form. Each one sits in a padded rst-box, because that is the card a form goes in."
+ },
+ "date-and-time": {
+ "title": "Date and time",
+ "blurb": "The four fields datetime.js enhances, each keeping its native input underneath, so a reader with no script gets the plain control and not a broken one."
+ },
+ "display": {
+ "title": "Display",
+ "blurb": "The small pieces of vocabulary a screen is assembled from: status, identity, progress and the one alert shape."
+ },
+ "form": {
+ "title": "Form",
+ "blurb": "The fields a person types into, in the states a real form puts them in. Each one sits in a padded rst-box, because that is the card a form goes in."
+ },
+ "formats": {
+ "title": "Dates, numbers and names",
+ "blurb": "How to show dates, numbers, money, names and addresses, and which ones people mix up."
+ },
+ "list-screen": {
+ "title": "List screen",
+ "blurb": "Generic list screen with title, toolbar, rows that work desktop and mobile."
+ },
+ "primitives": {
+ "title": "UI primitives",
+ "blurb": "The shapes a component cannot be, because they wrap a body only the caller knows: cards, data grids, menus and the shells' own chrome."
+ },
+ "route": {
+ "title": "Route",
+ "blurb": "The pieces that are a whole response rather than a piece of one."
+ },
+ "screens": {
+ "title": "Screens",
+ "blurb": "Whole screens built from the components. Start your own from one of them."
+ },
+ "shells": {
+ "title": "Shells",
+ "blurb": "The page frames an app is built in: a column, a top bar, a sidebar, a console and a stage. Each opens full width."
+ }
+ },
+ "examples": {
+ "actor": {
+ "blurb": "Who did something, as words: a person's name, or the agent and the person it acted for. No badge and no icon.",
+ "states": {
+ "an-agent-acting-for-a-person": "The agent's name, then the person it acted for. When the app itself did something, show nothing."
+ }
+ },
+ "api-tokens": {
+ "blurb": "The API token page: the one-time reveal, the create form, the list and the revoke buttons.",
+ "states": {
+ "four-tokens-two-of-them-live": "The revoke forms post to /tokens/revoke, /tokens/revoke-agent and /tokens/revoke-all, under the page's own path. Point them wherever your app handles them.",
+ "just-created-the-secret-shown-once": "Show this in the response to the create form itself, and do not redirect it: the secret is in that response and nowhere else."
+ }
+ },
+ "back-nav": {
+ "blurb": "The link back up one level, above a show or edit screen."
+ },
+ "badge": {
+ "blurb": "An uppercase bordered chip for small static markers. Distinct from status-pill, which is record status."
+ },
+ "box": {
+ "blurb": "The padded section card, and the heading that sits outside it."
+ },
+ "bulk-bar": {
+ "blurb": "Select mode's header strip, replacing the list bar: the count, an escalate link, and the actions menu.",
+ "states": {
+ "three-rows-selected": "The destructive item is last: the first entry is the surrounding form's implicit-Enter default."
+ }
+ },
+ "button": {
+ "blurb": "Buttons come in four variants and three sizes, and a link can look like one. For the spinner a form shows while it submits, see form-foot."
+ },
+ "callout": {
+ "blurb": "The one alert vocabulary: tinted strip, tone icon, optional bold title, one body paragraph.",
+ "states": {
+ "negative-announced-as-an-alert": "role=\"alert\" makes a screen reader announce it as soon as it appears. Keep it for messages that cannot wait."
+ }
+ },
+ "choice-field": {
+ "blurb": "Option cards: the preferred radio or checkbox group. The title stays short; the description explains."
+ },
+ "confirm-form": {
+ "blurb": "The confirm page's action row. Destructive actions get their own route rather than a dialog, so there is nothing to script.",
+ "states": {
+ "ordinary-default-cancel-wording": "The cancel link says Cancel, which is the usual wording. Change it when the form's own words are clearer."
+ }
+ },
+ "detail-list": {
+ "blurb": "Definition list about a record on a detail screen. Mono marks for machine-ish values."
+ },
+ "dropdown": {
+ "blurb": "A zero-JavaScript disclosure: a summary button over a panel of plain links.",
+ "states": {
+ "menu-idiom": "The details/summary menu behind header overflow menus and a list bar's filter, plus an applied filter as a removable chip."
+ }
+ },
+ "empty-state": {
+ "blurb": "The blank-state card. Empty state with affordances for starting steps."
+ },
+ "error-page": {
+ "blurb": "The whole body of an error response: the status, one honest sentence, a way back, and 500 references an admin can use.",
+ "states": {
+ "500-server-error": "A 500 is the one status with a reference: an operator can grep the logs for it.",
+ "anything-else-the-generic-pair": "Any status that has no wording of its own gets this generic pair: a title that says something is wrong and a body that says to try again."
+ }
+ },
+ "field": {
+ "blurb": "Label, input, optional hint, help and error. The general text-like control, with an explicit ID."
+ },
+ "field-check": {
+ "blurb": "The one toggle mechanism: a real checkbox skinned as a switch, keyboard and screen-reader intact."
+ },
+ "field-date": {
+ "blurb": "A native date input, plus the natural-language combobox datetime.js adds when the input carries data-rst-date. Type \"tomorrow\" into it.",
+ "states": {
+ "enhanced-input-type-tomorrow-or-in-3-weeks": "The words it parses are on data-rst-date-words, in English here. i18n/\u003clang\u003e.json has them in twelve languages, so the field can parse the language the page is in.",
+ "plain-the-bare-native-input": "No enhancement attributes at all, so datetime.js has nothing to find. The native picker still opens."
+ }
+ },
+ "field-daterange": {
+ "blurb": "A start and an end as one labelled fieldset. Give every input its own name and id.",
+ "states": {
+ "date-and-time-seeded": "Commit the start with the end still empty and datetime.js fills the end in an hour later. Nothing in the markup does that; the script does."
+ }
+ },
+ "field-datetime": {
+ "blurb": "A date and a time in one input, one value.",
+ "states": {
+ "enhanced-input-type-tomorrow-or-in-3-weeks": "The words it parses are on data-rst-date-words, in English here. i18n/\u003clang\u003e.json has them in twelve languages, so the field can parse the language the page is in.",
+ "plain-the-bare-native-input": "No enhancement attributes at all, so datetime.js has nothing to find. The native picker still opens."
+ }
+ },
+ "field-money": {
+ "blurb": "A money amount in one currency. The currency code shows beside the label and is sent with the form, so a currency that changed while someone was typing is caught on save."
+ },
+ "field-select": {
+ "blurb": "The field envelope around a native select. Add data-rst-select and select.js turns it into a filterable combobox; the native select is still what the form submits. Worth it from about ten options.",
+ "states": {
+ "hand-written-with-optgroups": "Grouped choices are optgroups you write yourself. select.js keeps the groups rather than dropping them; a select can also refuse the enhancement outright with data-rst-select=\"false\".",
+ "twelve-options-the-filterable-combobox": "Add data-rst-select from about ten options. Below that, leave it off: search over a handful of items is furniture, not help."
+ }
+ },
+ "field-text": {
+ "blurb": "One labelled text input. The required marker's star is aria-hidden; the input's required attribute is the programmatic signal.",
+ "states": {
+ "the-headline-field": "Give the main field of a form, a title or a name, rst-input=\"primary\" for a more prominent input."
+ }
+ },
+ "field-textarea": {
+ "blurb": "field-text's wrapper around a textarea."
+ },
+ "field-time": {
+ "blurb": "The same envelope around a native time input. The enhancement reads a clock rather than a calendar."
+ },
+ "field-url": {
+ "blurb": "A web address field that accepts example.com with or without https://. Whatever reads the form has to add the scheme and check the address."
+ },
+ "form-error": {
+ "blurb": "A form's summary error line, role=\"alert\". Leave it out when there is no error."
+ },
+ "form-foot": {
+ "blurb": "A form's closing row: one primary submit and an optional cancel link. Cancel is an anchor, because leaving a form is navigation.",
+ "states": {
+ "idle-the-button-before-anything-happens": "A button that CHANGES something gets a loading state; a button that only reveals something, e.g. a disclosure, a dropdown, a tab, does not. busy.js applies that rule to every submit button in every form, with nothing to opt into.",
+ "working-what-busy-js-writes-on-the-way-out": "Only the button that was clicked: every other submit button in the form keeps its name and its value, and the form itself is guarded against a second submit. data-busy=\"false\" opts out, on the form or on one button; data-busy-label replaces the text. With scripts off none of this happens and the form submits exactly as it always did, so a second submit is still your app's job to catch."
+ }
+ },
+ "form-layout": {
+ "blurb": "The attributes that give a form its rhythm and its save bar. They wrap a run of fields you compose yourself."
+ },
+ "format-abbr": {
+ "blurb": "The \u003cabbr\u003e tag shows the full wording when you hover. That does not work on a phone, and screen readers often skip it. Write the words out in full the first time as well."
+ },
+ "format-addresses": {
+ "blurb": "The \u003caddress\u003e tag is not for postal addresses. It is for saying who wrote the page. A delivery address is ordinary text. A list of staff is a list."
+ },
+ "format-dates": {
+ "blurb": "Put a date in a \u003ctime\u003e tag, with a version of the date a computer can read. Write the date itself however your readers expect to see it. Fiesta will not format it for you."
+ },
+ "format-durations": {
+ "blurb": "Use \u003ctime\u003e for a length of time too. The computer-readable version looks strange: PT2H30M means 2 hours 30 minutes."
+ },
+ "format-filesizes": {
+ "blurb": "Show a rounded size like 4 MB, and put the exact number of bytes in a \u003cdata\u003e tag. Screen readers read what is on screen and ignore the tag, so the rounded size has to be useful on its own."
+ },
+ "format-identifiers": {
+ "blurb": "Do not break these up. Order 4471, not order 4,471. Years, version numbers and port numbers are the same. Use rst-mono so they look like codes, and never put one in a \u003ctime\u003e tag, even when it looks like a date."
+ },
+ "format-money": {
+ "blurb": "Store each amount as a whole number of the currency's smallest unit, such as cents, with its currency code beside it. Write it the way the reader's language does. The currency always comes from your data, never from the reader."
+ },
+ "format-numbers": {
+ "blurb": "Break up long numbers so they can be read at a glance: 54,173 in English, 54.173 in German, ٥٤٬١٧٣ in Arabic. Never show a long number as plain digits. In a table, use digits that are all the same width, so the columns line up."
+ },
+ "format-output": {
+ "blurb": "When your page adds something up, put the answer in an \u003coutput\u003e tag. A running total, a converted price, a countdown."
+ },
+ "format-people": {
+ "blurb": "A name can be in any language, including ones that read right to left. When a name sits in the middle of a line, wrap it in \u003cbdi\u003e, or it will scramble the words around it. Use person to show a name with a picture beside it."
+ },
+ "format-ratios": {
+ "blurb": "Use \u003cmeter\u003e to show how full something is. Use \u003cprogress\u003e to show how far a job has got. Always print the number beside the bar. A bar on its own tells a blind reader nothing."
+ },
+ "help": {
+ "blurb": "A bordered question mark linking to a help article. Its CSS tooltip is decoration; the link carries its own full-sentence label."
+ },
+ "job-status": {
+ "blurb": "A background job's progress: a spinner, a line of text and a bar. The data-poll attributes say where a page could fetch a fresh copy and how often. Fiesta ships no script that does that, so wire it up yourself, and leave the attributes off once the job is done.",
+ "states": {
+ "running": "The data-poll attributes are where a script would fetch a fresh copy from, and how often. Nothing in Fiesta does, so on this page it stays running."
+ }
+ },
+ "layout-column": {
+ "blurb": "The plain centred page an app starts on: a skip link, a title, and the content column."
+ },
+ "layout-console": {
+ "blurb": "A bar across the top and a navigation rail down the side at once, the shape most admin consoles are. On a phone, the bar's menu opens as a card and the rail is an index page. No JavaScript needed."
+ },
+ "layout-console-page": {
+ "blurb": "A bar across the top and a navigation rail down the side at once, the shape most admin consoles are. On a phone, the bar's menu opens as a card and the rail is an index page. No JavaScript needed."
+ },
+ "layout-sidebar": {
+ "blurb": "A navigation rail beside the page. On a phone, the rail is an index page and every other page has a back control. No JavaScript needed."
+ },
+ "layout-sidebar-page": {
+ "blurb": "A navigation rail beside the page. On a phone, the rail is an index page and every other page has a back control. No JavaScript needed."
+ },
+ "layout-stage": {
+ "blurb": "One card in the middle of a full-page backdrop, for a screen that stands alone. The sign-in screen is what it is for."
+ },
+ "layout-topbar": {
+ "blurb": "Brand, navigation and an account menu across the top, with a footer under the page."
+ },
+ "list-bar": {
+ "blurb": "The toolbar at the top of a list card with search and filter."
+ },
+ "list-bar-search": {
+ "blurb": "Solo search form for search-only lists. An ordinary method=\"get\" form.",
+ "states": {
+ "empty-accessible-name-from-the-catalog": "No placeholder. The input's accessible name is its aria-label, which an app writes in the page's language.",
+ "with-a-query": "The ✕ is a link to the same screen with the query dropped. The status filter rides in a hidden input and survives it.",
+ "with-a-query-on-page-4": "The page number rides in a hidden input, so a new search would carry page=4 into a result set that no longer has four pages. The ✕ link drops it: point it at the list's first page."
+ }
+ },
+ "list-grid": {
+ "blurb": "The real data-table vocabulary: the card sets its columns once, rows only choose cells."
+ },
+ "list-row-action": {
+ "blurb": "One list row: the record's name as the primary link, a meta line, and a secondary action."
+ },
+ "list-search-submit": {
+ "blurb": "The search form's real submit button: visually hidden, in the tab order, and it un-hides on keyboard focus.",
+ "states": {
+ "default-label": "Nothing is visible here until you tab into it."
+ }
+ },
+ "locale-menu": {
+ "blurb": "The language switcher: a details menu of one-field POST forms, so the choice is sent to your app and the reader stays on the same path.",
+ "states": {
+ "twelve-languages-current-highlighted": "Each language is a one-field form, so choosing one is a POST your app answers. A static site can use a menu of plain links instead."
+ }
+ },
+ "media-grid": {
+ "blurb": "A grid of image tiles. Every frame is 4:3, so tall screenshots and wide banners sit in one even grid.",
+ "states": {
+ "screenshots-a-logo-and-a-banner": "Give a logo or a banner rst-media-frame=\"contain\". A screenshot keeps the default and shows its top. A tile with no description says \"No description\".",
+ "select-mode-one-tile-selected": "Wrap the grid and a bulk-bar in one form. Each tile's checkbox posts its value under the name \"id\"; rename it if your app expects another. Inside the form, menu items must be links."
+ }
+ },
+ "meter": {
+ "blurb": "A capacity bar with its number.",
+ "states": {
+ "over-budget": "Over budget, the bar is full and the number says by how much: write value=\"100\" and leave the real figure to the text."
+ }
+ },
+ "modal": {
+ "blurb": "A modal is its own URL, not client state: the page underneath, marked inert, with the panel over it and a plain link to close."
+ },
+ "notice": {
+ "blurb": "A one-line confirmation after a redirect: one sentence in a paragraph with role=\"status\". Leave it out when there is nothing to say."
+ },
+ "page-header": {
+ "blurb": "The screen's title bar: an h1, an optional subhead, and a primary action."
+ },
+ "pagination": {
+ "blurb": "A numbered strip of ordinary links, so paging survives JavaScript being off.",
+ "states": {
+ "first-page-default-accessible-name": "The nav's aria-label is the plain word Pagination. Say which list it pages when a page has more than one."
+ }
+ },
+ "person": {
+ "blurb": "Initials avatar, name and email. Quickly identifies people records."
+ },
+ "row-menu": {
+ "blurb": "A row's other actions, behind a ⋮ button. A destructive one links to its confirm page.",
+ "states": {
+ "links-a-form-and-a-destructive-item": "Put the destructive item last and end its label with …. It opens a page that asks before anything is deleted.",
+ "links-only-for-a-list-inside-a-selection-form": "Inside a form, every item must be a link. An action that posts goes through a page of its own."
+ }
+ },
+ "scheme-menu": {
+ "blurb": "The colour-scheme switch: a details menu of one-field POST forms for Light, Dark and System. Posted, the choice goes to your app; standalone, fiesta.js remembers it in the browser.",
+ "states": {
+ "standalone": "No app behind it: the forms have no action, and with data-rst-scheme-store=\"local\" on the \u003chtml\u003e element fiesta.js remembers the choice in the browser. The gallery's own header uses this one.",
+ "three-choices-dark-current": "Each scheme is a one-field form, so choosing one is a POST your app answers, and the page comes back with the chosen scheme marked current."
+ }
+ },
+ "seg-tabs": {
+ "blurb": "A segmented control of plain links. The current tab is aria-current on a link."
+ },
+ "selbox": {
+ "blurb": "The selection checkbox a list row wears in select mode. Its label restates the row's identity."
+ },
+ "shell-sidebar": {
+ "blurb": "The sidebar shell's chrome on a content page: on a phone, a back control to the index."
+ },
+ "shell-topbar": {
+ "blurb": "The topbar shell's own chrome, as markup. The whole page it belongs to is layout-topbar, under Shells."
+ },
+ "signin": {
+ "blurb": "The sign-in screen in every state it passes through: asking for an address, coming back, after the link is sent, when something went wrong, and the other ways in.",
+ "states": {
+ "a-passkey": "The fastest way in for someone who has one, and nothing to type. Offer a second way as well: people lose devices, and a passkey that will not work is a locked door.",
+ "after-the-link-is-sent": "Repeat the address back. It is the only chance to notice a typo before waiting for an email that will never come.",
+ "after-the-link-is-sent-in-another-browser": "When this browser did not send the address, the page does not guess it.",
+ "an-address-that-does-not-look-right": "The message belongs to the field, and that is where focus starts.",
+ "asking-for-an-address": "One field and one button. Nothing to remember and nothing to steal.",
+ "coming-back-with-an-email-link": "The address is on the button, so there is nothing to type.",
+ "coming-back-with-keymail": "The browser remembers how it got in last time. One tap sends the remembered address, which is checked again from scratch.",
+ "email-and-password": "If you do use passwords, use the same one-way-in layout, and put a way to reset directly under the button rather than hiding it. We do not recommend passwords: people reuse them, they leak, and you inherit the job of storing them safely. Use a link in your email plus a passkey, or passkeys on their own. The markup is here because some products still need it.",
+ "google-apple-and-the-rest": "We give you the buttons, drawn the way each company requires. We do not give you the sign-in itself: you wire that up to whichever provider you use.",
+ "on-the-way-to-keymail": "The page moves on by itself, with a link in case it does not.",
+ "when-keymail-could-not-confirm-it": "The button changes to send a link instead, so nobody is stuck.",
+ "when-keymail-was-offered-and-a-link-went-out": "One line says a link was sent this time. It does not guess why."
+ }
+ },
+ "stat": {
+ "blurb": "One number on a dashboard, with a label and how much it changed.",
+ "states": {
+ "a-shortened-number": "Showing 4.1k instead of 4120? Put the full number in a \u003cdata\u003e tag's value. Screen readers only read what is on screen, so keep anything a person needs in the number itself.",
+ "a-smaller-number": "You only need a label and a number. Everything else is optional.",
+ "the-big-number": "Give one stat in the row rst-stat=\"lead\" to make it big. It is the same component, just larger.",
+ "when-going-down-is-good": "You set the colour, not the component. Here the number went down and that is good news, so the change is green.",
+ "when-going-up-is-bad": "Here the number went up and that is bad news, so the change is red.",
+ "when-you-cannot-say": "Leave the colour out and the change is grey. Do that when you cannot say if it is good or bad."
+ }
+ },
+ "stat-band": {
+ "blurb": "A row of stats across the top of a dashboard."
+ },
+ "status-pill": {
+ "blurb": "A record's status as a tinted pill, colour and label.",
+ "states": {
+ "neutral": "Neutral is the default tone, so rst-tone=\"neutral\" could be left off."
+ }
+ },
+ "tblock": {
+ "blurb": "A bordered card whose body reveals only while its switch is on, via :has(). The switch is authoritative; the reveal is a display convenience."
+ }
+ },
+ "pages": {
+ "idioms": [
+ {
+ "idiom": "box",
+ "title": "Which card is which",
+ "body": "rst-list and rst-card have no padding by design: they hold a run of rows, and each row pads itself. Put a form, a paragraph, a strip of links or anything else that is not a row straight into one and it renders flush against the border; the text touching the edge is the tell. The padded card for arbitrary content is rst-box, with its heading as a sibling rst-box-head before it."
+ },
+ {
+ "idiom": "list-grid",
+ "title": "Screens stack vertically",
+ "body": "A screen is a column: page-header, then section-header + card, then the next section-header + card, in reading order. Horizontal arrangement is reserved for the idioms that ship it: rst-box-head, rst-field-row, rst-lbar, rst-lrow cells, rst-seg-tabs."
+ }
+ ]
+ }
+}
diff --git a/hack/seed/README.md b/hack/seed/README.md
index a1a1f83..8a8b62f 100644
--- a/hack/seed/README.md
+++ b/hack/seed/README.md
@@ -8,6 +8,8 @@ Fiesta's CSS, scripts, icons, examples, fixtures and language files were taken f
run.sh <rastrillo checkout> <seed commit> does the seeding. It adds a scratch worktree of Rastrillo at the seed commit under $TMPDIR, copies the two exporters from _rastrillo/ into it, runs them as Go tests, and removes the worktree whether or not they passed. The exporters write the examples, examples.json, the fixtures under check/testdata, the twelve i18n files and sources.json. Set FIESTA_SEED_DRYRUN=1 to write nothing and list the state labels that need copy review.
+run-prose.sh <rastrillo checkout> <seed commit> does the same for the prose: it reads the gallery's explanations at the seed commit, writes the ones that carry into examples/prose.json, and lists the rest in prose-review.json with the reason and, once reviewed, the approved replacement.
+
sources.json says where every example state came from: the partial, idiom, shell, format or screen in Rastrillo's gallery, and which of its states. check/testdata/fixtures/index.json does the same for the fixtures, and marks the page builders that were not seeded, with the reason.
The hashes below are the files copied byte for byte, so a later sync can tell what moved.
@@ -63,3 +65,8 @@ f7b196f6eaaaedb39c051ef784a910665da6df2dc2f269372e778c7bf163a0e3 examples/asset
60b046509f6eb5344bb28834a0c64e62fded8a467edca72867a3a9f7bbb3c127 examples/assets/sample-checkout.svg
87f400ef28f9b9ec8a0cfa605077f0913b750dd88acab4310aeb14871a1bdf97 examples/assets/sample-logo.svg
6f0ba1eecc8aa675ae91b82fc274ecc4f8abe24833c974d25479856e1e261472 examples/assets/sample-pricing.svg
+
+run-prose.sh's output, as of the last run, so a later run can tell whether the explanations moved.
+
+55852407c4ce823b707329e5a6a11de587f199dd3a7a6ef2238788d3fda6d714 examples/prose.json
+d6efc4ac8168dfed9ccd9971176de705c1d45932e059c40adf3dd9396f05786f hack/seed/prose-review.json
diff --git a/hack/seed/_rastrillo/prose_fiestaseed_test.go b/hack/seed/_rastrillo/prose_fiestaseed_test.go
index d2118c7..1db8bb9 100644
--- a/hack/seed/_rastrillo/prose_fiestaseed_test.go
+++ b/hack/seed/_rastrillo/prose_fiestaseed_test.go
@@ -165,7 +165,62 @@ var proseAlways = map[string]string{
// approvedProse is the review's answer, by slot. Nothing joins this
// map without copy review; a reseed writes these and never the
// rastrillo text they replace.
-var approvedProse = map[string]string{}
+var approvedProse = map[string]string{
+ "examples.actor.states.an-agent-acting-for-a-person": "The agent's name, then the person it acted for. When the app itself did something, show nothing.",
+ "examples.api-tokens.blurb": "The API token page: the one-time reveal, the create form, the list and the revoke buttons.",
+ "examples.api-tokens.states.four-tokens-two-of-them-live": "The revoke forms post to /tokens/revoke, /tokens/revoke-agent and /tokens/revoke-all, under the page's own path. Point them wherever your app handles them.",
+ "examples.api-tokens.states.just-created-the-secret-shown-once": "Show this in the response to the create form itself, and do not redirect it: the secret is in that response and nowhere else.",
+ "examples.callout.states.negative-announced-as-an-alert": "role=\"alert\" makes a screen reader announce it as soon as it appears. Keep it for messages that cannot wait.",
+ "examples.choice-field.blurb": "Option cards: the preferred radio or checkbox group. The title stays short; the description explains.",
+ "examples.confirm-form.states.ordinary-default-cancel-wording": "The cancel link says Cancel, which is the usual wording. Change it when the form's own words are clearer.",
+ "examples.error-page.states.anything-else-the-generic-pair": "Any status that has no wording of its own gets this generic pair: a title that says something is wrong and a body that says to try again.",
+ "examples.field-date.blurb": "A native date input, plus the natural-language combobox datetime.js adds when the input carries data-rst-date. Type \"tomorrow\" into it.",
+ "examples.field-date.states.enhanced-input-type-tomorrow-or-in-3-weeks": "The words it parses are on data-rst-date-words, in English here. i18n/<lang>.json has them in twelve languages, so the field can parse the language the page is in.",
+ "examples.field-daterange.blurb": "A start and an end as one labelled fieldset. Give every input its own name and id.",
+ "examples.field-daterange.states.date-and-time-seeded": "Commit the start with the end still empty and datetime.js fills the end in an hour later. Nothing in the markup does that; the script does.",
+ "examples.field-datetime.states.enhanced-input-type-tomorrow-or-in-3-weeks": "The words it parses are on data-rst-date-words, in English here. i18n/<lang>.json has them in twelve languages, so the field can parse the language the page is in.",
+ "examples.field-money.blurb": "A money amount in one currency. The currency code shows beside the label and is sent with the form, so a currency that changed while someone was typing is caught on save.",
+ "examples.field-select.blurb": "The field envelope around a native select. Add data-rst-select and select.js turns it into a filterable combobox; the native select is still what the form submits. Worth it from about ten options.",
+ "examples.field-select.states.hand-written-with-optgroups": "Grouped choices are optgroups you write yourself. select.js keeps the groups rather than dropping them; a select can also refuse the enhancement outright with data-rst-select=\"false\".",
+ "examples.field-select.states.twelve-options-the-filterable-combobox": "Add data-rst-select from about ten options. Below that, leave it off: search over a handful of items is furniture, not help.",
+ "examples.field-text.blurb": "One labelled text input. The required marker's star is aria-hidden; the input's required attribute is the programmatic signal.",
+ "examples.field-text.states.the-headline-field": "Give the main field of a form, a title or a name, rst-input=\"primary\" for a more prominent input.",
+ "examples.field-url.blurb": "A web address field that accepts example.com with or without https://. Whatever reads the form has to add the scheme and check the address.",
+ "examples.form-error.blurb": "A form's summary error line, role=\"alert\". Leave it out when there is no error.",
+ "examples.form-foot.states.idle-the-button-before-anything-happens": "A button that CHANGES something gets a loading state; a button that only reveals something, e.g. a disclosure, a dropdown, a tab, does not. busy.js applies that rule to every submit button in every form, with nothing to opt into.",
+ "examples.form-foot.states.working-what-busy-js-writes-on-the-way-out": "Only the button that was clicked: every other submit button in the form keeps its name and its value, and the form itself is guarded against a second submit. data-busy=\"false\" opts out, on the form or on one button; data-busy-label replaces the text. With scripts off none of this happens and the form submits exactly as it always did, so a second submit is still your app's job to catch.",
+ "examples.form-layout.blurb": "The attributes that give a form its rhythm and its save bar. They wrap a run of fields you compose yourself.",
+ "examples.format-dates.blurb": "Put a date in a <time> tag, with a version of the date a computer can read. Write the date itself however your readers expect to see it. Fiesta will not format it for you.",
+ "examples.format-money.blurb": "Store each amount as a whole number of the currency's smallest unit, such as cents, with its currency code beside it. Write it the way the reader's language does. The currency always comes from your data, never from the reader.",
+ "examples.job-status.blurb": "A background job's progress: a spinner, a line of text and a bar. The data-poll attributes say where a page could fetch a fresh copy and how often. Fiesta ships no script that does that, so wire it up yourself, and leave the attributes off once the job is done.",
+ "examples.job-status.states.running": "The data-poll attributes are where a script would fetch a fresh copy from, and how often. Nothing in Fiesta does, so on this page it stays running.",
+ "examples.layout-column.blurb": "The plain centred page an app starts on: a skip link, a title, and the content column.",
+ "examples.list-bar-search.states.empty-accessible-name-from-the-catalog": "No placeholder. The input's accessible name is its aria-label, which an app writes in the page's language.",
+ "examples.list-bar-search.states.with-a-query": "The ✕ is a link to the same screen with the query dropped. The status filter rides in a hidden input and survives it.",
+ "examples.list-bar-search.states.with-a-query-on-page-4": "The page number rides in a hidden input, so a new search would carry page=4 into a result set that no longer has four pages. The ✕ link drops it: point it at the list's first page.",
+ "examples.locale-menu.blurb": "The language switcher: a details menu of one-field POST forms, so the choice is sent to your app and the reader stays on the same path.",
+ "examples.locale-menu.states.twelve-languages-current-highlighted": "Each language is a one-field form, so choosing one is a POST your app answers. A static site can use a menu of plain links instead.",
+ "examples.media-grid.states.screenshots-a-logo-and-a-banner": "Give a logo or a banner rst-media-frame=\"contain\". A screenshot keeps the default and shows its top. A tile with no description says \"No description\".",
+ "examples.media-grid.states.select-mode-one-tile-selected": "Wrap the grid and a bulk-bar in one form. Each tile's checkbox posts its value under the name \"id\"; rename it if your app expects another. Inside the form, menu items must be links.",
+ "examples.meter.states.over-budget": "Over budget, the bar is full and the number says by how much: write value=\"100\" and leave the real figure to the text.",
+ "examples.notice.blurb": "A one-line confirmation after a redirect: one sentence in a paragraph with role=\"status\". Leave it out when there is nothing to say.",
+ "examples.pagination.states.first-page-default-accessible-name": "The nav's aria-label is the plain word Pagination. Say which list it pages when a page has more than one.",
+ "examples.scheme-menu.blurb": "The colour-scheme switch: a details menu of one-field POST forms for Light, Dark and System. Posted, the choice goes to your app; standalone, fiesta.js remembers it in the browser.",
+ "examples.scheme-menu.states.standalone": "No app behind it: the forms have no action, and with data-rst-scheme-store=\"local\" on the <html> element fiesta.js remembers the choice in the browser. The gallery's own header uses this one.",
+ "examples.scheme-menu.states.three-choices-dark-current": "Each scheme is a one-field form, so choosing one is a POST your app answers, and the page comes back with the chosen scheme marked current.",
+ "examples.seg-tabs.blurb": "A segmented control of plain links. The current tab is aria-current on a link.",
+ "examples.shell-topbar.blurb": "The topbar shell's own chrome, as markup. The whole page it belongs to is layout-topbar, under Shells.",
+ "examples.signin.blurb": "The sign-in screen in every state it passes through: asking for an address, coming back, after the link is sent, when something went wrong, and the other ways in.",
+ "examples.signin.states.asking-for-an-address": "One field and one button. Nothing to remember and nothing to steal.",
+ "examples.signin.states.email-and-password": "If you do use passwords, use the same one-way-in layout, and put a way to reset directly under the button rather than hiding it. We do not recommend passwords: people reuse them, they leak, and you inherit the job of storing them safely. Use a link in your email plus a passkey, or passkeys on their own. The markup is here because some products still need it.",
+ "examples.stat.states.a-shortened-number": "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.",
+ "examples.stat.states.the-big-number": "Give one stat in the row rst-stat=\"lead\" to make it big. It is the same component, just larger.",
+ "examples.status-pill.states.neutral": "Neutral is the default tone, so rst-tone=\"neutral\" could be left off.",
+ "families.choices-and-actions.blurb": "The controls that pick from options, and the rows that end a form. Each one sits in a padded rst-box, because that is the card a form goes in.",
+ "families.form.blurb": "The fields a person types into, in the states a real form puts them in. Each one sits in a padded rst-box, because that is the card a form goes in.",
+ "families.screens.blurb": "Whole screens built from the components. Start your own from one of them.",
+ "families.shells.blurb": "The page frames an app is built in: a column, a top bar, a sidebar, a console and a stage. Each opens full width.",
+}
type proser struct {
t *testing.T
diff --git a/hack/seed/prose-review.json b/hack/seed/prose-review.json
new file mode 100644
index 0000000..d079885
--- /dev/null
+++ b/hack/seed/prose-review.json
@@ -0,0 +1,326 @@
+[
+ {
+ "id": "examples.actor.states.an-agent-acting-for-a-person",
+ "seed": "Set ui.ActorName once at boot to turn a subject into a name. The app itself, the zero Actor, renders nothing.",
+ "reason": "mentions ui.",
+ "text": "The agent's name, then the person it acted for. When the app itself did something, show nothing."
+ },
+ {
+ "id": "examples.api-tokens.blurb",
+ "seed": "The token page from apitoken's PageData: the one-time reveal, the create form, the list and the revoke buttons.",
+ "reason": "uses the template argument PageData",
+ "text": "The API token page: the one-time reveal, the create form, the list and the revoke buttons."
+ },
+ {
+ "id": "examples.api-tokens.states.four-tokens-two-of-them-live",
+ "seed": "Pass what Handlers hand Render as Page, and the page's own path as Path. The revoke forms post to Path/revoke, Path/revoke-agent and Path/revoke-all; set RevokePath, RevokeAgentPath or RevokeAllPath to change one.",
+ "reason": "uses the template argument RevokePath",
+ "text": "The revoke forms post to /tokens/revoke, /tokens/revoke-agent and /tokens/revoke-all, under the page's own path. Point them wherever your app handles them."
+ },
+ {
+ "id": "examples.api-tokens.states.just-created-the-secret-shown-once",
+ "seed": "Render the create form's response with Created set, and do not redirect it: the secret is in that response and nowhere else.",
+ "reason": "uses the template argument Created",
+ "text": "Show this in the response to the create form itself, and do not redirect it: the secret is in that response and nowhere else."
+ },
+ {
+ "id": "examples.callout.states.negative-announced-as-an-alert",
+ "seed": "Alert adds role=\"alert\" to display important messages.",
+ "reason": "uses the template argument Alert",
+ "text": "role=\"alert\" makes a screen reader announce it as soon as it appears. Keep it for messages that cannot wait."
+ },
+ {
+ "id": "examples.choice-field.blurb",
+ "seed": "Option cards: the preferred radio or checkbox group. Title stays short; Desc explains.",
+ "reason": "uses the template argument Desc",
+ "text": "Option cards: the preferred radio or checkbox group. The title stays short; the description explains."
+ },
+ {
+ "id": "examples.confirm-form.states.ordinary-default-cancel-wording",
+ "seed": "No CancelLabel, so the cancel link is worded from the catalog.",
+ "reason": "uses the template argument CancelLabel",
+ "text": "The cancel link says Cancel, which is the usual wording. Change it when the form's own words are clearer."
+ },
+ {
+ "id": "examples.error-page.states.anything-else-the-generic-pair",
+ "seed": "Five statuses are worded in the framework catalog. A sixth falls back to the generic pair rather than rendering a missing key's name at a reader.",
+ "reason": "the framework catalog is rastrillo's; Fiesta's example is six documents",
+ "text": "Any status that has no wording of its own gets this generic pair: a title that says something is wrong and a body that says to try again."
+ },
+ {
+ "id": "examples.field-date.blurb",
+ "seed": "A native date input, plus the natural-language combobox datetime.js arms unless the caller passes Plain. Type \"tomorrow\" into it.",
+ "reason": "uses the template argument Plain",
+ "text": "A native date input, plus the natural-language combobox datetime.js adds when the input carries data-rst-date. Type \"tomorrow\" into it."
+ },
+ {
+ "id": "examples.field-date.states.enhanced-input-type-tomorrow-or-in-3-weeks",
+ "seed": "The words it parses ride out on data-rst-date-words, resolved through this page's catalog: the field parses the language the page is in.",
+ "reason": "the page has no catalog; the words are on the attribute, and i18n/\u003clang\u003e.json has the other languages",
+ "text": "The words it parses are on data-rst-date-words, in English here. i18n/\u003clang\u003e.json has them in twelve languages, so the field can parse the language the page is in."
+ },
+ {
+ "id": "examples.field-daterange.blurb",
+ "seed": "A start and an end as one labelled. Names must be unique since IDs are derived.",
+ "reason": "the derived ids are the template's; in the HTML they are written out",
+ "text": "A start and an end as one labelled fieldset. Give every input its own name and id."
+ },
+ {
+ "id": "examples.field-daterange.states.date-and-time-seeded",
+ "seed": "Commit the start with the end still empty and the browser fills the end in an hour later. Nothing is seeded server-side.",
+ "reason": "mentions a server",
+ "text": "Commit the start with the end still empty and datetime.js fills the end in an hour later. Nothing in the markup does that; the script does."
+ },
+ {
+ "id": "examples.field-datetime.states.enhanced-input-type-tomorrow-or-in-3-weeks",
+ "seed": "The words it parses ride out on data-rst-date-words, resolved through this page's catalog: the field parses the language the page is in.",
+ "reason": "the page has no catalog; the words are on the attribute, and i18n/\u003clang\u003e.json has the other languages",
+ "text": "The words it parses are on data-rst-date-words, in English here. i18n/\u003clang\u003e.json has them in twelve languages, so the field can parse the language the page is in."
+ },
+ {
+ "id": "examples.field-money.blurb",
+ "seed": "A money amount in one currency. The currency code shows beside the label and is sent with the form, so a currency that changed while someone was typing is caught on save. Pair it with form.Field's Currency on the server.",
+ "reason": "mentions a server",
+ "text": "A money amount in one currency. The currency code shows beside the label and is sent with the form, so a currency that changed while someone was typing is caught on save."
+ },
+ {
+ "id": "examples.field-select.blurb",
+ "seed": "field's envelope around a native select. Past ten options the select arms the filterable combobox in select.js; the native select is still what the form submits.",
+ "reason": "the ten-option rule is the template's; in the HTML the data-rst-select attribute is what arms select.js",
+ "text": "The field envelope around a native select. Add data-rst-select and select.js turns it into a filterable combobox; the native select is still what the form submits. Worth it from about ten options."
+ },
+ {
+ "id": "examples.field-select.states.hand-written-with-optgroups",
+ "seed": "Options here is a flat list, so grouped choices are markup the app writes. select.js renders the groups rather than dropping them; a hand-written select can also refuse the enhancement outright with data-rst-select=\"false\".",
+ "reason": "uses the template argument Options",
+ "text": "Grouped choices are optgroups you write yourself. select.js keeps the groups rather than dropping them; a select can also refuse the enhancement outright with data-rst-select=\"false\"."
+ },
+ {
+ "id": "examples.field-select.states.twelve-options-the-filterable-combobox",
+ "seed": "Past ten options the partial arms select.js. Below ten it does not: search over a handful of items is furniture, not help.",
+ "reason": "mentions a partial",
+ "text": "Add data-rst-select from about ten options. Below that, leave it off: search over a handful of items is furniture, not help."
+ },
+ {
+ "id": "examples.field-text.blurb",
+ "seed": "One labelled text input, ids derived from Name. The required marker's star is aria-hidden. The input's required attribute is the programmatic signal.",
+ "reason": "Name is the template's argument; in the HTML the ids are written out",
+ "text": "One labelled text input. The required marker's star is aria-hidden; the input's required attribute is the programmatic signal."
+ },
+ {
+ "id": "examples.field-text.states.the-headline-field",
+ "seed": "Add Primary to the main form field, e.g. a title or a name to get a more visually prominent input.",
+ "reason": "uses the template argument Primary",
+ "text": "Give the main field of a form, a title or a name, rst-input=\"primary\" for a more prominent input."
+ },
+ {
+ "id": "examples.field-url.blurb",
+ "seed": "A web address field that accepts example.com with or without https://. Pair it with form.URL on the server.",
+ "reason": "mentions a server",
+ "text": "A web address field that accepts example.com with or without https://. Whatever reads the form has to add the scheme and check the address."
+ },
+ {
+ "id": "examples.form-error.blurb",
+ "seed": "A form's summary error line, role=\"alert\". Takes a plain string; renders nothing when there is no error.",
+ "reason": "describes the template's argument, which the HTML does not have",
+ "text": "A form's summary error line, role=\"alert\". Leave it out when there is no error."
+ },
+ {
+ "id": "examples.form-foot.states.idle-the-button-before-anything-happens",
+ "seed": "A button that CHANGES something gets a loading state; a button that only reveals something, e.g. a disclosure, a dropdown, a tab, does not. rastrillo.js applies that rule to every submit button in every form, with nothing to opt into.",
+ "reason": "mentions rastrillo",
+ "text": "A button that CHANGES something gets a loading state; a button that only reveals something, e.g. a disclosure, a dropdown, a tab, does not. busy.js applies that rule to every submit button in every form, with nothing to opt into."
+ },
+ {
+ "id": "examples.form-foot.states.working-what-busy-js-writes-on-the-way-out",
+ "seed": "Only the button that was clicked: every other submit button in the form keeps its name and its value, and the form itself is guarded against a second submit. data-busy=\"false\" opts out, on the form or on one button; data-busy-label replaces the text. With scripts off none of this happens and the form submits exactly as it always did, so idempotency stays the server's job.",
+ "reason": "mentions a server",
+ "text": "Only the button that was clicked: every other submit button in the form keeps its name and its value, and the form itself is guarded against a second submit. data-busy=\"false\" opts out, on the form or on one button; data-busy-label replaces the text. With scripts off none of this happens and the form submits exactly as it always did, so a second submit is still your app's job to catch."
+ },
+ {
+ "id": "examples.form-layout.blurb",
+ "seed": "The attributes that give a form its rhythm and its save bar. No partial emits these; they wrap a run of fields you compose yourself.",
+ "reason": "mentions a partial",
+ "text": "The attributes that give a form its rhythm and its save bar. They wrap a run of fields you compose yourself."
+ },
+ {
+ "id": "examples.format-dates.blurb",
+ "seed": "Put a date in a \u003ctime\u003e tag, with a version of the date a computer can read. Write the date itself however your readers expect to see it. Rastrillo will not format it for you.",
+ "reason": "mentions rastrillo",
+ "text": "Put a date in a \u003ctime\u003e tag, with a version of the date a computer can read. Write the date itself however your readers expect to see it. Fiesta will not format it for you."
+ },
+ {
+ "id": "examples.format-money.blurb",
+ "seed": "Store each amount as a whole number of the currency's smallest unit, such as cents, with its currency code beside it. Show it with `money`, which writes it the way the reader's language does. The currency always comes from your data, never from the reader.",
+ "reason": "money is a template function",
+ "text": "Store each amount as a whole number of the currency's smallest unit, such as cents, with its currency code beside it. Write it the way the reader's language does. The currency always comes from your data, never from the reader."
+ },
+ {
+ "id": "examples.job-status.blurb",
+ "seed": "A background job's polled fragment. The data-poll attribute is emitted only while the job runs: leaving it out is how polling stops.",
+ "reason": "Fiesta ships no script that polls, and nothing here emits the attribute",
+ "text": "A background job's progress: a spinner, a line of text and a bar. The data-poll attributes say where a page could fetch a fresh copy and how often. Fiesta ships no script that does that, so wire it up yourself, and leave the attributes off once the job is done."
+ },
+ {
+ "id": "examples.job-status.states.running",
+ "seed": "This one polls: it is the only sample on the page that asks the network for anything, and on a static site it simply never resolves.",
+ "reason": "Fiesta ships no script that polls, so this state asks the network for nothing",
+ "text": "The data-poll attributes are where a script would fetch a fresh copy from, and how often. Nothing in Fiesta does, so on this page it stays running."
+ },
+ {
+ "id": "examples.layout-column.blurb",
+ "seed": "The plain centred page every scaffolded app starts on: a skip link, a title, and the content column.",
+ "reason": "Fiesta scaffolds nothing",
+ "text": "The plain centred page an app starts on: a skip link, a title, and the content column."
+ },
+ {
+ "id": "examples.list-bar-search.states.empty-accessible-name-from-the-catalog",
+ "seed": "No Placeholder. The input's accessible name comes from the framework catalog in the current language.",
+ "reason": "uses the template argument Placeholder",
+ "text": "No placeholder. The input's accessible name is its aria-label, which an app writes in the page's language."
+ },
+ {
+ "id": "examples.list-bar-search.states.with-a-query",
+ "seed": "The ✕ is a link to the same screen with the query dropped. The status filter rides in Hidden and survives it.",
+ "reason": "uses the template argument Hidden",
+ "text": "The ✕ is a link to the same screen with the query dropped. The status filter rides in a hidden input and survives it."
+ },
+ {
+ "id": "examples.list-bar-search.states.with-a-query-on-page-4",
+ "seed": "Nothing in Hidden says which pair is the page, so the default would carry page=4 into a result set that no longer has four pages. ClearHref is how an app resets it.",
+ "reason": "uses the template argument Hidden",
+ "text": "The page number rides in a hidden input, so a new search would carry page=4 into a result set that no longer has four pages. The ✕ link drops it: point it at the list's first page."
+ },
+ {
+ "id": "examples.locale-menu.blurb",
+ "seed": "The language switcher: a details menu of one-field POST forms to /_locale, so the choice lands in the locale cookie and the reader stays on the same path.",
+ "reason": "mentions a cookie",
+ "text": "The language switcher: a details menu of one-field POST forms, so the choice is sent to your app and the reader stays on the same path."
+ },
+ {
+ "id": "examples.locale-menu.states.twelve-languages-current-highlighted",
+ "seed": "This one posts to /_locale, so it needs a server. Its Items come from rastrillo.LocaleItems(r). The switcher in this page's own header is the links-only version.",
+ "reason": "mentions rastrillo",
+ "text": "Each language is a one-field form, so choosing one is a POST your app answers. A static site can use a menu of plain links instead."
+ },
+ {
+ "id": "examples.media-grid.states.screenshots-a-logo-and-a-banner",
+ "seed": "Set Fit to \"contain\" for a logo or a banner. A screenshot keeps the default and shows its top. A tile with no Desc says \"No description\".",
+ "reason": "uses the template argument Fit",
+ "text": "Give a logo or a banner rst-media-frame=\"contain\". A screenshot keeps the default and shows its top. A tile with no description says \"No description\"."
+ },
+ {
+ "id": "examples.media-grid.states.select-mode-one-tile-selected",
+ "seed": "Wrap the grid and a bulk-bar in one form. Each checkbox posts its tile's Select value under the name \"id\"; set SelectName to change it. Inside the form, menu items must be links.",
+ "reason": "uses the template argument SelectName",
+ "text": "Wrap the grid and a bulk-bar in one form. Each tile's checkbox posts its value under the name \"id\"; rename it if your app expects another. Inside the form, menu items must be links."
+ },
+ {
+ "id": "examples.meter.states.over-budget",
+ "seed": "The fill is clamped to 100% in the partial.",
+ "reason": "mentions a partial",
+ "text": "Over budget, the bar is full and the number says by how much: write value=\"100\" and leave the real figure to the text."
+ },
+ {
+ "id": "examples.notice.blurb",
+ "seed": "A one-line confirmation after a redirect. Takes a plain string, not a dict, and renders nothing for an empty one.",
+ "reason": "describes the template's argument, which the HTML does not have",
+ "text": "A one-line confirmation after a redirect: one sentence in a paragraph with role=\"status\". Leave it out when there is nothing to say."
+ },
+ {
+ "id": "examples.pagination.states.first-page-default-accessible-name",
+ "seed": "No Label, so the nav's accessible name comes from the catalog.",
+ "reason": "Label is the template's argument, and the catalog is rastrillo's",
+ "text": "The nav's aria-label is the plain word Pagination. Say which list it pages when a page has more than one."
+ },
+ {
+ "id": "examples.scheme-menu.blurb",
+ "seed": "The colour-scheme switch: a details menu of one-field POST forms to /_theme for Light, Dark and System, so the choice lands in the scheme cookie and the reader stays on the same page.",
+ "reason": "mentions /_theme",
+ "text": "The colour-scheme switch: a details menu of one-field POST forms for Light, Dark and System. Posted, the choice goes to your app; standalone, fiesta.js remembers it in the browser."
+ },
+ {
+ "id": "examples.scheme-menu.states.standalone",
+ "seed": "This one posts to /_theme, so it needs a server. Its Scheme comes from rastrillo.SchemeFrom(r); ui.ForRequest carries it. The switch in this page's own bar is the gallery's own.",
+ "reason": "rastrillo's note describes a server; this state's page remembers the choice itself",
+ "text": "No app behind it: the forms have no action, and with data-rst-scheme-store=\"local\" on the \u003chtml\u003e element fiesta.js remembers the choice in the browser. The gallery's own header uses this one."
+ },
+ {
+ "id": "examples.scheme-menu.states.three-choices-dark-current",
+ "seed": "This one posts to /_theme, so it needs a server. Its Scheme comes from rastrillo.SchemeFrom(r); ui.ForRequest carries it. The switch in this page's own bar is the gallery's own.",
+ "reason": "mentions ui.",
+ "text": "Each scheme is a one-field form, so choosing one is a POST your app answers, and the page comes back with the chosen scheme marked current."
+ },
+ {
+ "id": "examples.seg-tabs.blurb",
+ "seed": "A segmented control of server-rendered links. The current tab is aria-current on a link.",
+ "reason": "mentions a server",
+ "text": "A segmented control of plain links. The current tab is aria-current on a link."
+ },
+ {
+ "id": "examples.shell-topbar.blurb",
+ "seed": "The topbar shell's own chrome, as markup. Layout ships it as a whole template.",
+ "reason": "mentions a template",
+ "text": "The topbar shell's own chrome, as markup. The whole page it belongs to is layout-topbar, under Shells."
+ },
+ {
+ "id": "examples.signin.blurb",
+ "seed": "",
+ "reason": "no rastrillo source: screenDocs() explains each screen and not the set, so this one sentence is new",
+ "text": "The sign-in screen in every state it passes through: asking for an address, coming back, after the link is sent, when something went wrong, and the other ways in."
+ },
+ {
+ "id": "examples.signin.states.asking-for-an-address",
+ "seed": "One field and one button. Nothing to remember and nothing to steal. Rastrillo does this out of the box.",
+ "reason": "mentions rastrillo",
+ "text": "One field and one button. Nothing to remember and nothing to steal."
+ },
+ {
+ "id": "examples.signin.states.email-and-password",
+ "seed": "If you do use passwords, use the same one-way-in layout, and put a way to reset directly under the button rather than hiding it. We do not recommend passwords: Rastrillo does not ship this screen. People reuse passwords, they leak, and you inherit the job of storing them safely. Use a link in your email plus a passkey, or passkeys on their own. The markup is here because some products still need it.",
+ "reason": "mentions rastrillo",
+ "text": "If you do use passwords, use the same one-way-in layout, and put a way to reset directly under the button rather than hiding it. We do not recommend passwords: people reuse them, they leak, and you inherit the job of storing them safely. Use a link in your email plus a passkey, or passkeys on their own. The markup is here because some products still need it."
+ },
+ {
+ "id": "examples.stat.states.a-shortened-number",
+ "seed": "Showing 4.1k instead of 4120? Put the full number in Exact. Screen readers only read what is on screen, so keep anything a person needs in the number itself.",
+ "reason": "uses the template argument Exact",
+ "text": "Showing 4.1k instead of 4120? Put the full number in a \u003cdata\u003e tag's value. Screen readers only read what is on screen, so keep anything a person needs in the number itself."
+ },
+ {
+ "id": "examples.stat.states.the-big-number",
+ "seed": "Add Lead to one stat in the row to make it big. It is the same component, just larger.",
+ "reason": "uses the template argument Lead",
+ "text": "Give one stat in the row rst-stat=\"lead\" to make it big. It is the same component, just larger."
+ },
+ {
+ "id": "examples.status-pill.states.neutral",
+ "seed": "Tone defaults to neutral, so this call leaves it out.",
+ "reason": "uses the template argument Tone",
+ "text": "Neutral is the default tone, so rst-tone=\"neutral\" could be left off."
+ },
+ {
+ "id": "families.choices-and-actions.blurb",
+ "seed": "The controls that pick from options, and the rows that end a form. Each one sits in a padded rst-box, because that is the rule these partials assume.",
+ "reason": "mentions a partial",
+ "text": "The controls that pick from options, and the rows that end a form. Each one sits in a padded rst-box, because that is the card a form goes in."
+ },
+ {
+ "id": "families.form.blurb",
+ "seed": "The fields a person types into, in the states a real form puts them in. Each one sits in a padded rst-box, because that is the rule these partials assume.",
+ "reason": "mentions a partial",
+ "text": "The fields a person types into, in the states a real form puts them in. Each one sits in a padded rst-box, because that is the card a form goes in."
+ },
+ {
+ "id": "families.screens.blurb",
+ "seed": "Examples of screens using the design system. You can use these as starter templates for your own apps.",
+ "reason": "mentions a template",
+ "text": "Whole screens built from the components. Start your own from one of them."
+ },
+ {
+ "id": "families.shells.blurb",
+ "seed": "The page frames rastrillo new can scaffold. Each opens full width.",
+ "reason": "mentions rastrillo",
+ "text": "The page frames an app is built in: a column, a top bar, a sidebar, a console and a stage. Each opens full width."
+ }
+]
diff --git a/internal/gallery/copy.go b/internal/gallery/copy.go
index fefd570..6479942 100644
--- a/internal/gallery/copy.go
+++ b/internal/gallery/copy.go
@@ -27,19 +27,10 @@ var text = map[string]string{
"gallery.nav.examples": "Examples",
"gallery.nav.tokens": "Tokens",
"gallery.nav.icons": "Icons",
- // The index: its heading, its lead, and a heading per family.
- "gallery.index.heading": "Examples",
- "gallery.index.lead": "Every state of every example in the {theme} theme, each as a page of its own. Open an example to see its states live, with the markup beside each one.",
- "gallery.family.list-screen": "List screen",
- "gallery.family.display": "Display",
- "gallery.family.form": "Form",
- "gallery.family.choices-and-actions": "Choices and actions",
- "gallery.family.date-and-time": "Date and time",
- "gallery.family.route": "Route",
- "gallery.family.primitives": "Primitives",
- "gallery.family.shells": "Shells",
- "gallery.family.formats": "Formats",
- "gallery.family.screens": "Screens",
+ // The index: its heading and its lead. The families' headings
+ // and sentences are the prose's, from examples/prose.json.
+ "gallery.index.heading": "Examples",
+ "gallery.index.lead": "Every state of every example in the {theme} theme, each as a page of its own. Open an example to see its states live, with the markup beside each one.",
// An example's page: the sentence under its heading, the HTML tab,
// the frame's accessible name, and the note naming the routes a
// sample stands for.
diff --git a/internal/gallery/gallery.css b/internal/gallery/gallery.css
index 75d0611..673d030 100644
--- a/internal/gallery/gallery.css
+++ b/internal/gallery/gallery.css
@@ -27,6 +27,11 @@ main[rst-page] h1, main[rst-page] h2 { overflow-wrap: anywhere; }
.gallery-lead { color: var(--rst-text-muted); margin: 0 0 var(--rst-sp-4); max-width: 62ch; }
.gallery-note { border-inline-start: 2px solid var(--rst-line-strong); color: var(--rst-text-muted); font-size: var(--rst-fs-sm); margin: 0 0 var(--rst-sp-4); max-width: 62ch; padding-inline-start: var(--rst-sp-3); }
.gallery-family h2 { font-size: var(--rst-fs-base); margin: var(--rst-sp-5) 0 var(--rst-sp-2); }
+.gallery-family .gallery-lead { font-size: var(--rst-fs-sm); }
+/* An example page's family, above its heading: the family's name and
+ its sentence, as a kicker back to where the index files it. */
+.gallery-family-head { color: var(--rst-text-muted); font-size: var(--rst-fs-sm); margin: 0 0 var(--rst-sp-2); max-width: 62ch; }
+.gallery-family-head b { color: var(--rst-text); font-weight: 600; }
.gallery-list { display: grid; gap: var(--rst-sp-2); grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); list-style: none; margin: 0; padding: 0; }
.gallery-list a { align-items: center; display: inline-flex; min-block-size: var(--rst-target); }
diff --git a/internal/gallery/gallery.go b/internal/gallery/gallery.go
index 0e8f704..198e4c3 100644
--- a/internal/gallery/gallery.go
+++ b/internal/gallery/gallery.go
@@ -110,13 +110,19 @@ type site struct {
// schemeScripts are the scripts that state's head asks for, by
// file name.
schemeScripts []string
- families []family
- icons []icon
+ // prose is the design system's explanations, from examples/prose.json.
+ prose *prose
+ families []family
+ icons []icon
}
-// family is one run of examples on the index.
+// family is one run of examples on the index: its heading and the
+// sentence under it, the rules quoted at its head, and its examples.
type family struct {
Key string
+ Title string
+ Blurb string
+ Rules []idiomRule
Examples []example.Entry
}
@@ -134,6 +140,9 @@ func Render(src fs.FS, opts Options) (map[string][]byte, error) {
return nil, fmt.Errorf("gallery: %w", err)
}
s := &site{src: src, m: m, mount: cleanMount(opts.Mount), out: map[string][]byte{}}
+ if s.prose, err = loadProse(src); err != nil {
+ return nil, fmt.Errorf("gallery: %w", err)
+ }
if err := s.assets(); err != nil {
return nil, fmt.Errorf("gallery: %w", err)
}
@@ -141,6 +150,9 @@ func Render(src fs.FS, opts Options) (map[string][]byte, error) {
return nil, fmt.Errorf("gallery: %w", err)
}
s.families = groupFamilies(m.Examples)
+ if err := s.explain(); err != nil {
+ return nil, fmt.Errorf("gallery: %w", err)
+ }
if err := s.readIcons(); err != nil {
return nil, fmt.Errorf("gallery: %w", err)
}
@@ -303,6 +315,33 @@ func groupFamilies(entries []example.Entry) []family {
return out
}
+// explain gives each family its heading and blurb from the prose, and
+// puts each idiom rule at the head of the family that holds the idiom
+// it names, which is the primitives. A family the prose has no heading
+// for is an error: the index would show a family with no name.
+func (s *site) explain() error {
+ at := map[string]int{}
+ for i := range s.families {
+ f := &s.families[i]
+ fp, ok := s.prose.Families[f.Key]
+ if !ok || fp.Title == "" {
+ return fmt.Errorf("%s has no title for the %s family", proseFile, f.Key)
+ }
+ f.Title, f.Blurb = fp.Title, fp.Blurb
+ at[f.Key] = i
+ }
+ for _, rule := range s.prose.Pages.Idioms {
+ name, _, ok := s.m.Idiom(rule.Idiom)
+ if !ok {
+ return fmt.Errorf("%s quotes a rule for the %s idiom, which the manifest does not have", proseFile, rule.Idiom)
+ }
+ e, _ := s.m.Entry(name)
+ f := &s.families[at[e.Family]]
+ f.Rules = append(f.Rules, rule)
+ }
+ return nil
+}
+
// readIcons lists every icon in the source, by slug.
func (s *site) readIcons() error {
entries, err := fs.ReadDir(s.src, "icons")
@@ -358,6 +397,17 @@ func (p page) HTMLAttrs() template.HTMLAttr { return p.s.htmlAttrs }
// Asset is the URL of one of the gallery's assets.
func (p page) Asset(name string) string { return p.s.url("assets/" + name) }
+// Icon is one of Fiesta's icons, inline, for the gallery's own
+// furniture.
+func (p page) Icon(slug string) template.HTML {
+ for _, ic := range p.s.icons {
+ if ic.Slug == slug {
+ return ic.Markup
+ }
+ }
+ return ""
+}
+
// Scripts are the scripts every gallery page loads, for the scheme
// menu.
func (p page) Scripts() []string { return p.s.schemeScripts }
@@ -444,12 +494,17 @@ var pageTemplate = template.Must(template.New("page").Funcs(funcs).Parse(`<!doct
</html>
`))
-// indexTemplate is the index's body: the examples, by family.
+// indexTemplate is the index's body: the examples, by family, each
+// family headed by its sentence and, for the primitives, the two rules
+// about cards and columns.
var indexTemplate = template.Must(template.New("index").Funcs(funcs).Parse(`<h1>{{say "gallery.index.heading"}}</h1>
<p class="gallery-lead">{{say "gallery.index.lead" "theme" .Theme}}</p>
{{range .Families}}<section class="gallery-family">
-<h2 id="{{.Key}}">{{say (print "gallery.family." .Key)}}</h2>
-<ul class="gallery-list">{{range .Examples}}
+<h2 id="{{.Key}}">{{.Title}}</h2>
+{{if .Blurb}}<p class="gallery-lead">{{.Blurb}}</p>
+{{end}}{{range .Rules}}<div rst-callout rst-tone="info"><span rst-callout-ic aria-hidden="true">{{$.Icon "info"}}</span><div rst-callout-body><strong>{{.Title}}</strong>
+<p>{{.Body}}</p></div></div>
+{{end}}<ul class="gallery-list">{{range .Examples}}
<li><a class="rst-mono" data-gallery-nav href="{{$.Href (print .Name ".html")}}">{{.Name}}</a></li>{{end}}
</ul>
</section>
@@ -459,6 +514,7 @@ var indexTemplate = template.Must(template.New("index").Funcs(funcs).Parse(`<h1>
type state struct {
ID string
Label string
+ Note string
Frame string
Title string
Page bool
@@ -466,14 +522,18 @@ type state struct {
Routes string
}
-// exampleTemplate is one example's page: a heading, then every state
-// as a frame of its own document, its HTML, and the routes it stands
-// for.
-var exampleTemplate = template.Must(template.New("example").Funcs(funcs).Parse(`<h1 class="rst-mono">{{.Entry.Name}}</h1>
-<p class="gallery-lead">{{say "gallery.example.lead"}}</p>
+// exampleTemplate is one example's page: its family's heading and
+// sentence, its own heading with its sentence under it, then every
+// state as a frame of its own document, with the note on the state
+// under its label, its HTML, and the routes it stands for.
+var exampleTemplate = template.Must(template.New("example").Funcs(funcs).Parse(`<p class="gallery-family-head"><b>{{.Family.Title}}</b> {{.Family.Blurb}}</p>
+<h1 class="rst-mono">{{.Entry.Name}}</h1>
+{{if .Blurb}}<p rst-page-header-sub>{{.Blurb}}</p>
+{{end}}<p class="gallery-lead">{{say "gallery.example.lead"}}</p>
{{range .States}}<section class="gallery-state" id="{{.ID}}" data-gallery-state="{{$.Entry.Name}}/{{.ID}}">
<h2 class="gallery-label">{{.Label}}</h2>
-<div class="gallery-view"><iframe class="gallery-frame{{if .Page}} gallery-frame--page{{end}}" title="{{.Title}}" src="{{.Frame}}" loading="lazy"></iframe></div>
+{{if .Note}}<p class="gallery-note">{{.Note}}</p>
+{{end}}<div class="gallery-view"><iframe class="gallery-frame{{if .Page}} gallery-frame--page{{end}}" title="{{.Title}}" src="{{.Frame}}" loading="lazy"></iframe></div>
<details class="gallery-code"><summary>{{say "gallery.html"}}</summary>
<pre class="gallery-src rst-mono" tabindex="0"><code>{{.Source}}</code></pre>
</details>
@@ -550,6 +610,7 @@ func (s *site) theme(theme string, rtl bool) error {
}
for _, e := range s.m.Examples {
+ ex := s.prose.Examples[e.Name]
var states []state
for _, st := range e.States {
doc, err := example.Document(s.src, e.Name, st.ID)
@@ -577,6 +638,7 @@ func (s *site) theme(theme string, rtl bool) error {
states = append(states, state{
ID: st.ID,
Label: st.Label,
+ Note: ex.States[st.ID],
Frame: s.url(rel),
Title: say("gallery.frame.title", "example", e.Name, "state", st.Label),
Page: tall(e),
@@ -587,9 +649,11 @@ func (s *site) theme(theme string, rtl bool) error {
b.Reset()
if err := exampleTemplate.Execute(&b, struct {
page
+ Family family
Entry example.Entry
+ Blurb string
States []state
- }{p, e, states}); err != nil {
+ }{p, s.familyOf(e), e, ex.Blurb, states}); err != nil {
return err
}
if err := s.write(p, e.Name+".html", e.Name, "examples", b.String()); err != nil {
@@ -599,6 +663,16 @@ func (s *site) theme(theme string, rtl bool) error {
return nil
}
+// familyOf is the family e sits in.
+func (s *site) familyOf(e example.Entry) family {
+ for _, f := range s.families {
+ if f.Key == e.Family {
+ return f
+ }
+ }
+ return family{Key: e.Family}
+}
+
// tall says whether an example's frames are drawn as pages rather
// than fitted to their content: a layout, a screen, a shell idiom, and
// the modal, whose overlay covers whatever viewport it gets.
diff --git a/internal/gallery/pages_a11y_browser_test.go b/internal/gallery/pages_a11y_browser_test.go
new file mode 100644
index 0000000..7ead6cd
--- /dev/null
+++ b/internal/gallery/pages_a11y_browser_test.go
@@ -0,0 +1,112 @@
+//go:build browser
+
+package gallery
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "sort"
+ "testing"
+ "time"
+
+ "github.com/chromedp/chromedp"
+
+ "amadan.net/rastrillo/fiesta"
+ "amadan.net/rastrillo/fiesta/internal/axe"
+ "amadan.net/rastrillo/fiesta/internal/harness"
+)
+
+// The gallery's own pages under axe. a11y_browser_test.go scans every
+// state's frame document, which is where the examples' markup lives;
+// this file scans the pages around them, which carry the design
+// system's explanations: the families' headings and sentences, the
+// rules at the head of the primitives, and the notes under the states.
+
+// prosePages are the pages scanned in every theme and scheme: the
+// three furniture pages, and the four example pages that carry prose
+// of every kind. button has notes on its states, dropdown has the
+// idiom's note on one state, signin has the summary copy review gave
+// it and a note per screen, and format-money has a format's blurb.
+var prosePages = []string{"index.html", "tokens.html", "icons.html", "button.html", "dropdown.html", "signin.html", "format-money.html"}
+
+// TestTheGalleryPagesPassAxe: the gallery pages that carry prose,
+// scanned with the WCAG 2.2 AA tag set in day, plain and signal with
+// data-theme light and dark, and the right-to-left index in day.
+// Expected: no violations. The rules axe could not decide are logged
+// per leg, never silently dropped.
+//
+// Already passing when written: the pages had no violation to find.
+// The check was proved live by a named mutation, a text input with no
+// label planted in indexTemplate, which it reported as label on every
+// index scan.
+func TestTheGalleryPagesPassAxe(t *testing.T) {
+ rig := harness.New(t, func(string) http.Handler { return newTree(t, nil) })
+ ctx, cancel := context.WithTimeout(rig.Context(), 10*time.Minute)
+ defer cancel()
+
+ type leg struct {
+ theme string
+ rtl bool
+ pages []string
+ }
+ var legs []leg
+ for _, theme := range fiesta.ThemeNames() {
+ legs = append(legs, leg{theme, false, prosePages})
+ }
+ legs = append(legs, leg{fiesta.ThemeNames()[0], true, []string{"index.html"}})
+
+ scans, failed := 0, 0
+ for _, leg := range legs {
+ name := leg.theme
+ if leg.rtl {
+ name += " rtl"
+ }
+ incomplete := map[string]int{}
+ start := time.Now()
+ for _, file := range leg.pages {
+ dir := leg.theme + "/"
+ if leg.rtl {
+ dir += rtlDir
+ }
+ where := name + " " + file
+ mustRun(t, ctx,
+ chromedp.EmulateViewport(1280, 900),
+ chromedp.Navigate(rig.Origin+href(dir+file)),
+ chromedp.WaitReady("body"),
+ )
+ // The body's ground is read back after each flip, and the
+ // two must differ: a dark scan of a page still painted
+ // light would be the light scan twice.
+ ground := map[string]string{}
+ for _, scheme := range []string{"light", "dark"} {
+ var bg string
+ mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`document.documentElement.setAttribute("data-theme", %q); getComputedStyle(document.body).backgroundColor`, scheme), &bg))
+ ground[scheme] = bg
+ res, err := axe.Scan(ctx, "", axe.DefaultTags())
+ if err != nil {
+ t.Fatalf("%s %s: %v", where, scheme, err)
+ }
+ scans++
+ for _, id := range res.Incomplete {
+ incomplete[id]++
+ }
+ failed += report(t, where+" "+scheme, res.Violations)
+ }
+ if ground["light"] == ground["dark"] {
+ t.Fatalf("%s: the body paints %s under both schemes, so the flip to dark did not take", where, ground["light"])
+ }
+ }
+ var ids []string
+ for id, n := range incomplete {
+ ids = append(ids, fmt.Sprintf("%s x%d", id, n))
+ }
+ sort.Strings(ids)
+ t.Logf("%s: scanned in %s; needs review, axe could not decide: %v", name, time.Since(start).Round(time.Second), ids)
+ }
+ if scans == 0 {
+ t.Fatal("no page was scanned")
+ }
+ t.Logf("%d scans, %d violations", scans, failed)
+ rig.Screen("main", "the last page")
+}
diff --git a/internal/gallery/prose.go b/internal/gallery/prose.go
index d68fb86..ecde811 100644
--- a/internal/gallery/prose.go
+++ b/internal/gallery/prose.go
@@ -47,8 +47,8 @@ type pagesProse struct {
Idioms []idiomRule `json:"idioms"`
}
-// idiomRule is one rule, quoted on the index at the head of the
-// primitives and again on the page of the idiom it names.
+// idiomRule is one rule, quoted on the index at the head of the family
+// that holds the idiom it names, which is the primitives.
type idiomRule struct {
Idiom string `json:"idiom"`
Title string `json:"title"`