rastrillo / fiesta Public

The landing page: bunting, install link, a live sample and the six reasons

Human

Prompt

Task 4 of Fiesta part 3: build the landing page in English from the approved preview, with the bunting, the install link and its Copy button, a fully interactive sample, the six reasons and the footer; every string from the catalog; the head restoring the visitor's theme before first paint; the untagged and browser tests the plan names, each seen red by name; make ci green in a clean worktree.

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

site/landing/page.html.tmpl is the landing page, rendered by internal/www's landing(siteDir, lang) from the catalog Strings reads, with fiesta.Version in the footer; Pages writes it as index.html in place of the stand-in. The head reads localStorage's theme inside a try/catch and document.writes one render-blocking link for /1/fiesta.css, /1/fiesta-plain.css or /1/fiesta-signal.css, with a noscript link to Day, then /static/site.css, /1/fiesta.js without defer so it restores the scheme before first paint, and the deferred light.js, anchor.js, site.js and bunting.js. The theme choice is rst-seg-tabs anchors in a bar that starts hidden; site.js shows it, swaps the link, remembers and marks the choice. The sample is a window built from Fiesta attributes: five panes behind sidebar links, one shown at a time with JS and all in a column without, course tabs the same way, Search and Settings as GET forms to #sample that site.js keeps on the page with a status-region message, and a switch fiesta.js drags. Copy is hidden until the clipboard API is there, says Copied for two seconds, and on failure selects the link and says so. bunting.js is the mockup's rope and cloth without the dev harness, hung only while reduced motion is off and taken down when it turns on, repainted on rst-scheme and OS scheme changes; its still SVG carries its colours as attributes, so site.css holds Fiesta tokens only. The footer carries the facts line, the brand line and the four link columns. On a phone the window runs edge to edge, the stats sit two by two and a row's status stays beside its text, scoped to the sample and listed in site/FOLLOW-UPS.md. serve_test.go's siteHandler serves a site.Build tree as the edge does, with rewrites, directory indexes, 404s and 405s, and can delay a file; serveSite wraps it for Tasks 5 and 6. Four untagged tests hold the section order, the public URLs, the link box and that every text surface comes from the catalog; twelve browser drives cover axe in three themes and both schemes, reflow at 320, scripts off, the theme and scheme coming back before first paint, a broken stored theme, the panes and courses, the forms, the drag, Copy, the bunting under reduced motion and the language menu on a phone. TestSiteWritesWhatBuildReturns' fixture carries the template so it stays a whole site source.
Agent notes
The three untagged tests were red by name against a stub template; the catalog test passed against the stub and carries its own control, a planted word it must report. The twelve browser drives were red by assertion against the stub in under a minute, each naming the element the page lacked, then green on the real page after three fixes to the drives themselves: waiting for the form's navigation before reading the URL, scrolling the switch into view, and focus emulation for the clipboard. The first-paint drive compares --rst-bg rather than --rst-surface, since Day and Plain share a white surface in light. id="themes" sits on the stage around the theme bar, since a hidden bar cannot be a link's target without JS. TestServeSiteServesLikeTheEdge holds the test server itself and was proved by disabling the rewrite. axe reports color-contrast as undecidable on every scan, never as a violation. make ci was green in a clean worktree before this commit.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev eacecbe5ba76b3e4e76743e08e6483206dfa0ec4 parent f59b6f3
13 files changed, +2485 −27
  • cmd/fiesta/release_test.go +9 −0
  • internal/www/export_test.go +4 −0
  • internal/www/landing.go +57 −0
  • internal/www/landing_browser_test.go +861 −0
  • internal/www/landing_test.go +312 −0
  • internal/www/serve_test.go +227 −0
  • internal/www/www.go +7 −22
  • internal/www/www_test.go +3 −1
  • site/FOLLOW-UPS.md +17 −4
  • site/landing/page.html.tmpl +285 −0
  • site/static/bunting.js +456 −0
  • site/static/site.css +132 −0
  • site/static/site.js +115 −0

CI passed — run details

CI log
=== gofmt ===
=== no-rastrillo ===
go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 .
ok  	amadan.net/rastrillo/fiesta	0.527s
=== 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.09s)
PASS
ok  	amadan.net/rastrillo/fiesta/internal/build	0.107s
=== 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.507s
ok  	amadan.net/rastrillo/fiesta/check	8.478s
ok  	amadan.net/rastrillo/fiesta/cmd/fiesta	11.439s
ok  	amadan.net/rastrillo/fiesta/example	0.326s
ok  	amadan.net/rastrillo/fiesta/internal/axe	0.217s
ok  	amadan.net/rastrillo/fiesta/internal/build	1.800s
ok  	amadan.net/rastrillo/fiesta/internal/codeview	0.030s
ok  	amadan.net/rastrillo/fiesta/internal/gallery	9.001s
?   	amadan.net/rastrillo/fiesta/internal/harness	[no test files]
ok  	amadan.net/rastrillo/fiesta/internal/markup	0.014s
ok  	amadan.net/rastrillo/fiesta/internal/nodetest	0.498s
ok  	amadan.net/rastrillo/fiesta/internal/site	5.782s
ok  	amadan.net/rastrillo/fiesta/internal/tokens	0.019s
ok  	amadan.net/rastrillo/fiesta/internal/www	3.690s
--- 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' .
5:27PM INF Unknown SCM platform. Use --platform to include links in findings. host=amadan.net
5:27PM INF 1 commits scanned.
5:27PM INF scanned ~5783048 bytes (5.78 MB) in 4.72s
5:27PM 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	18.304s
--- 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	257.753s
--- 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	279.025s
--- 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	290.932s
--- 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 25 tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	114.490s
--- 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 24 of 25 tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	57.403s
--- 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 13 of 25 tests
ok  	amadan.net/rastrillo/fiesta/internal/www	32.537s
--- 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 12 of 25 tests
ok  	amadan.net/rastrillo/fiesta/internal/www	21.878s
diff --git a/cmd/fiesta/release_test.go b/cmd/fiesta/release_test.go
index 5614bbe..3e3464d 100644
--- a/cmd/fiesta/release_test.go
+++ b/cmd/fiesta/release_test.go
@@ -395,6 +395,15 @@ func TestSiteWritesWhatBuildReturns(t *testing.T) {
t.Fatal(err)
}
}
+ // The landing page's template, the repository's own: what the
+ // catalogs fill in.
+ tmpl, err := os.ReadFile(filepath.Join("..", "..", "site", "landing", "page.html.tmpl"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(siteDir, "landing", "page.html.tmpl"), tmpl, 0o644); err != nil {
+ t.Fatal(err)
+ }
out := filepath.Join(root, "out", "site")
if err := run([]string{"site", "-releases", releases, "-site", siteDir, "-out", out}, io.Discard); err != nil {
t.Fatalf("fiesta site: %v", err)
diff --git a/internal/www/export_test.go b/internal/www/export_test.go
new file mode 100644
index 0000000..cb27023
--- /dev/null
+++ b/internal/www/export_test.go
@@ -0,0 +1,4 @@
+package www
+
+// Landing is landing, for the tests in www_test.
+var Landing = landing
diff --git a/internal/www/landing.go b/internal/www/landing.go
new file mode 100644
index 0000000..9456af8
--- /dev/null
+++ b/internal/www/landing.go
@@ -0,0 +1,57 @@
+package www
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "io/fs"
+
+ "amadan.net/rastrillo/fiesta"
+)
+
+// landingTemplate is the landing page's one template, under the site
+// source. Every language renders from it with its own catalog.
+const landingTemplate = "landing/page.html.tmpl"
+
+// page is what the template sees: the language and its direction for
+// the html element, the version the footer shows, and the catalog
+// behind T.
+type page struct {
+ Lang, Dir, Version string
+ strings map[string]string
+}
+
+// T is one catalog string by key. A key the catalog lacks is an error,
+// so a typo in the template fails the render rather than rendering an
+// empty element.
+func (p page) T(key string) (string, error) {
+ s, ok := p.strings[key]
+ if !ok {
+ return "", fmt.Errorf("the landing page asks for %q, which the catalog does not have", key)
+ }
+ return s, nil
+}
+
+// landing renders the landing page in lang from the site source: the
+// template at landing/page.html.tmpl filled from Strings' catalog, with
+// the version from fiesta.Version. html/template escapes every string
+// for where it lands, so the install line's < reads as itself.
+func landing(siteDir fs.FS, lang string) ([]byte, error) {
+ strings, err := Strings(siteDir, lang)
+ if err != nil {
+ return nil, err
+ }
+ src, err := fs.ReadFile(siteDir, landingTemplate)
+ if err != nil {
+ return nil, fmt.Errorf("www: reading the landing page's template: %w", err)
+ }
+ tmpl, err := template.New(landingTemplate).Parse(string(src))
+ if err != nil {
+ return nil, fmt.Errorf("www: %w", err)
+ }
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, page{Lang: lang, Dir: Dir(lang), Version: fiesta.Version, strings: strings}); err != nil {
+ return nil, fmt.Errorf("www: rendering the landing page in %s: %w", lang, err)
+ }
+ return buf.Bytes(), nil
+}
diff --git a/internal/www/landing_browser_test.go b/internal/www/landing_browser_test.go
new file mode 100644
index 0000000..c4282c4
--- /dev/null
+++ b/internal/www/landing_browser_test.go
@@ -0,0 +1,861 @@
+//go:build browser
+
+// The landing page in a real Chromium, served as the edge serves it:
+// the site.Build tree over the committed releases, through the same
+// handler every www drive uses. Each drive is one journey a reader
+// makes: a phone, a browser with scripts off, a returning visitor, a
+// click through the sample, a copy of the install line.
+package www_test
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/chromedp/cdproto/browser"
+ "github.com/chromedp/cdproto/emulation"
+ "github.com/chromedp/cdproto/input"
+ cdppage "github.com/chromedp/cdproto/page"
+ "github.com/chromedp/cdproto/runtime"
+ "github.com/chromedp/chromedp"
+
+ "amadan.net/rastrillo/fiesta/internal/axe"
+ "amadan.net/rastrillo/fiesta/internal/harness"
+ "amadan.net/rastrillo/fiesta/internal/www"
+)
+
+// siteRig boots a browser on the built site, and hands back the
+// handler, for a drive that delays a file or reads the request log.
+func siteRig(t *testing.T, opts ...harness.Option) (*harness.Rig, *siteHandler) {
+ t.Helper()
+ var h *siteHandler
+ rig := harness.New(t, func(string) http.Handler {
+ h = newSiteHandler(t, builtSite(t))
+ return h
+ }, opts...)
+ return rig, h
+}
+
+func mustRun(t *testing.T, ctx context.Context, actions ...chromedp.Action) {
+ t.Helper()
+ if err := chromedp.Run(ctx, actions...); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// at evaluates a JS expression returning a JSON string into v.
+func at(t *testing.T, ctx context.Context, js string, v any, opts ...chromedp.EvaluateOption) {
+ t.Helper()
+ var raw string
+ if err := chromedp.Run(ctx, chromedp.Evaluate(js, &raw, opts...)); err != nil {
+ t.Fatalf("evaluating: %v\n%s", err, js)
+ }
+ if err := json.Unmarshal([]byte(raw), v); err != nil {
+ t.Fatalf("decoding %q: %v", raw, err)
+ }
+}
+
+func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) }
+
+// onNewDocument runs js in every document the page opens from now on,
+// before any of the page's own scripts.
+func onNewDocument(t *testing.T, ctx context.Context, js string) {
+ t.Helper()
+ mustRun(t, ctx, chromedp.ActionFunc(func(ctx context.Context) error {
+ _, err := cdppage.AddScriptToEvaluateOnNewDocument(js).Do(ctx)
+ return err
+ }))
+}
+
+// soon waits up to ten seconds for sel to be visible: long enough on a
+// loaded box, short enough that a page without the element fails by
+// its name rather than by the test's deadline.
+func soon(sel string) chromedp.Action {
+ return chromedp.ActionFunc(func(ctx context.Context) error {
+ ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+ if err := chromedp.WaitVisible(sel, chromedp.ByQuery).Do(ctx); err != nil {
+ return fmt.Errorf("%s never became visible: %w", sel, err)
+ }
+ return nil
+ })
+}
+
+// open loads the landing page at the given viewport, waits for it to
+// load, and requires the sample to be on it: a page without the sample
+// is not the landing page, and the drive fails here by name.
+func open(t *testing.T, ctx context.Context, rig *harness.Rig, w, h int64) {
+ t.Helper()
+ mustRun(t, ctx, chromedp.EmulateViewport(w, h), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("body"),
+ chromedp.Evaluate(`new Promise(r => document.readyState === "complete" ? r() : addEventListener("load", r, {once: true}))`, nil, awaitPromise))
+ var present bool
+ mustRun(t, ctx, chromedp.Evaluate(`!!document.getElementById("sample")`, &present))
+ if !present {
+ t.Fatal("the page has no #sample; this is not the landing page")
+ }
+}
+
+// catalog is the English catalog, for the words a drive expects to see.
+func catalog(t *testing.T) map[string]string {
+ t.Helper()
+ s, err := www.Strings(siteSource(t).FS(), "en")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return s
+}
+
+// visibleJS reports, for a selector, how many matches there are and
+// how many are rendered (have a box on the page).
+const visibleJS = `((sel) => { const all = [...document.querySelectorAll(sel)]; return JSON.stringify({Total: all.length, Shown: all.filter(e => e.getClientRects().length > 0).length}); })`
+
+type shown struct{ Total, Shown int }
+
+func visible(t *testing.T, ctx context.Context, sel string) shown {
+ t.Helper()
+ var s shown
+ at(t, ctx, visibleJS+fmt.Sprintf("(%q)", sel), &s)
+ return s
+}
+
+// themeURL is the stylesheet each theme choice loads.
+var themeURL = map[string]string{"day": "/1/fiesta.css", "plain": "/1/fiesta-plain.css", "signal": "/1/fiesta-signal.css"}
+
+// chooseTheme presses one theme tab and waits for its stylesheet to be
+// the one in effect.
+func chooseTheme(t *testing.T, ctx context.Context, theme string) {
+ t.Helper()
+ mustRun(t, ctx, soon(`.site-themebar`),
+ chromedp.Click(fmt.Sprintf(`[data-site-theme=%q]`, theme), chromedp.ByQuery))
+ deadline := time.Now().Add(10 * time.Second)
+ for {
+ var ok bool
+ mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`(() => { const l = document.getElementById("site-theme"); return !!l && l.getAttribute("href") === %q && !!l.sheet; })()`, themeURL[theme]), &ok))
+ if ok {
+ return
+ }
+ if !time.Now().Before(deadline) {
+ t.Fatalf("the %s stylesheet never became the page's theme", theme)
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+}
+
+// chooseScheme picks one scheme from the theme bar's menu, as a reader
+// would: open the menu, press the button.
+func chooseScheme(t *testing.T, ctx context.Context, scheme string) {
+ t.Helper()
+ btn := fmt.Sprintf(`[rst-scheme] input[value=%q] ~ button`, scheme)
+ mustRun(t, ctx,
+ chromedp.Click(`[rst-scheme] > summary`, chromedp.ByQuery),
+ soon(btn),
+ chromedp.Click(btn, chromedp.ByQuery),
+ )
+}
+
+// currentScheme is the scheme button carrying aria-current, "" for none.
+func currentScheme(t *testing.T, ctx context.Context) string {
+ t.Helper()
+ var s string
+ mustRun(t, ctx, chromedp.Evaluate(`(() => { const b = document.querySelector("[rst-scheme] button[aria-current]"); return b ? b.form.elements.scheme.value : ""; })()`, &s))
+ return s
+}
+
+// TestTheLandingPassesAxe: the whole page, in Day, Plain and Signal,
+// each light and dark, with the WCAG 2.2 AA tag set. Each theme is
+// chosen from the theme bar, as a reader would, and each scheme is
+// set on the root, the way fiesta.js sets it.
+func TestTheLandingPassesAxe(t *testing.T) {
+ rig, _ := siteRig(t)
+ ctx, cancel := context.WithTimeout(rig.Context(), 5*time.Minute)
+ defer cancel()
+ open(t, ctx, rig, 1280, 900)
+ scans, failed := 0, 0
+ for _, theme := range []string{"day", "plain", "signal"} {
+ chooseTheme(t, ctx, theme)
+ 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", theme, scheme, err)
+ }
+ scans++
+ for _, v := range res.Violations {
+ failed++
+ nodes := v.Nodes
+ if len(nodes) > 5 {
+ nodes = append(nodes[:5], fmt.Sprintf("… and %d more", len(v.Nodes)-5))
+ }
+ t.Errorf("%s %s: %s (%s): %s\n %s", theme, scheme, v.ID, v.Impact, v.Help, strings.Join(nodes, "\n "))
+ }
+ t.Logf("%s %s: needs review, axe could not decide: %v", theme, scheme, res.Incomplete)
+ }
+ if ground["light"] == ground["dark"] {
+ t.Fatalf("%s: the body paints %s under both schemes, so the flip to dark did not take", theme, ground["light"])
+ }
+ }
+ mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.removeAttribute("data-theme")`, nil))
+ t.Logf("%d scans, %d violations", scans, failed)
+ rig.Screen("body", "the landing page")
+}
+
+// measureJS reports whether the document scrolls sideways, and the
+// elements that reach past either edge.
+const measureJS = `(() => {
+ const de = document.documentElement;
+ const w = Math.round(de.getBoundingClientRect().width);
+ const over = [];
+ document.querySelectorAll("body *").forEach(el => {
+ const r = el.getBoundingClientRect();
+ if (r.width === 0) return;
+ if (r.right > w + 1 || r.left < -1) {
+ const cls = (el.className || "").toString().trim().split(/\s+/)[0];
+ over.push(el.tagName.toLowerCase() + (cls ? "." + cls : "") + " [" + Math.round(r.left) + "…" + Math.round(r.right) + "]");
+ }
+ });
+ return JSON.stringify({Doc: de.scrollWidth, Client: w, Body: document.body.scrollWidth, Over: over.slice(0, 8)});
+})()`
+
+// TestTheLandingHasNoHorizontalScrollAt320 is WCAG 1.4.10 Reflow on
+// the landing page: at a 320 CSS-pixel viewport nothing scrolls
+// sideways. The bunting is allowed to be wider than the page: it is
+// clipped by its own box, and the check is the document's scroll
+// width, which clipping keeps.
+func TestTheLandingHasNoHorizontalScrollAt320(t *testing.T) {
+ rig, _ := siteRig(t)
+ ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
+ defer cancel()
+ mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error {
+ return emulation.SetDeviceMetricsOverride(320, 640, 1, false).Do(c)
+ }), chromedp.Navigate(rig.Origin+"/"), soon("#sample"))
+ var got struct {
+ Doc, Client, Body int
+ Over []string
+ }
+ at(t, ctx, measureJS, &got)
+ if got.Doc > got.Client || got.Body > got.Client {
+ t.Errorf("the landing page scrolls sideways at 320px (WCAG 1.4.10): document %dpx, body %dpx in a %dpx page\n widest: %s",
+ got.Doc, got.Body, got.Client, strings.Join(got.Over, "\n "))
+ }
+ rig.Screen("#sample", "the page at 320")
+}
+
+// TestTheLandingReadsWithNoScript: with JavaScript off the page still
+// reads in full: the headline, the link box, every sample pane's
+// heading and the six reasons are on the screen; the theme bar and
+// the Copy button are not; and submitting the sample's Settings form
+// leaves the reader on the page, at the sample.
+func TestTheLandingReadsWithNoScript(t *testing.T) {
+ rig, h := siteRig(t)
+ ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
+ defer cancel()
+ mustRun(t, ctx, emulation.SetScriptExecutionDisabled(true), chromedp.EmulateViewport(1280, 900),
+ chromedp.Navigate(rig.Origin+"/"), soon("#reasons"))
+ var ran bool
+ mustRun(t, ctx, chromedp.Evaluate(`!!document.getElementById("site-theme")`, &ran))
+ if ran {
+ t.Fatal("the head's script wrote the theme link; scripts are not off, and this drive proves nothing")
+ }
+ for sel, want := range map[string]int{
+ "#headline": 1,
+ "#install code": 1,
+ "#sample .site-pane h2": 5,
+ "#reasons [rst-card]": 6,
+ } {
+ if s := visible(t, ctx, sel); s.Total != want || s.Shown != want {
+ t.Errorf("%s: %d on the page, %d shown; want %d shown with scripts off", sel, s.Total, s.Shown, want)
+ }
+ }
+ for _, sel := range []string{".site-themebar", ".site-copy"} {
+ if s := visible(t, ctx, sel); s.Total != 1 || s.Shown != 0 {
+ t.Errorf("%s: %d on the page, %d shown; want 1 hidden with scripts off", sel, s.Total, s.Shown)
+ }
+ }
+ h.Seen(true)
+ mustRun(t, ctx, chromedp.Click(`#pane-settings button[type="submit"]`, chromedp.ByQuery))
+ // The submit is a navigation: wait for the URL to move before
+ // reading it, since the old document answers until it does.
+ var loc string
+ deadline := time.Now().Add(10 * time.Second)
+ for {
+ mustRun(t, ctx, chromedp.Location(&loc))
+ if loc != rig.Origin+"/" || !time.Now().Before(deadline) {
+ break
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+ mustRun(t, ctx, soon("#reasons"))
+ if !strings.HasPrefix(loc, rig.Origin+"/?") || !strings.HasSuffix(loc, "#sample") {
+ t.Errorf("after submitting Settings with scripts off the page is at %s; want the same page with a query, at #sample", loc)
+ }
+ for _, p := range h.Seen(false) {
+ if p != "/" && !strings.HasPrefix(p, "/1/") && !strings.HasPrefix(p, "/static/") {
+ t.Errorf("the submit asked the server for %s", p)
+ }
+ }
+ rig.Screen("#reasons", "the page after the submit")
+}
+
+// firstFrameJS queues a reading from the head, before any stylesheet
+// or script of the page's own: the first animation frame's theme
+// variables, stylesheets, scheme and root attribute, which is what the
+// first paint showed.
+const firstFrameJS = `requestAnimationFrame(() => {
+ const cs = getComputedStyle(document.documentElement);
+ window.__first = {
+ Bg: cs.getPropertyValue("--rst-bg").trim(),
+ Sheets: [...document.styleSheets].map(s => s.href ? new URL(s.href).pathname : "inline"),
+ Scheme: cs.colorScheme,
+ Theme: document.documentElement.getAttribute("data-theme") || "",
+ Ready: document.readyState,
+ };
+})`
+
+type firstFrame struct {
+ Bg, Scheme, Theme, Ready string
+ Sheets []string
+}
+
+func rootBg(t *testing.T, ctx context.Context) string {
+ t.Helper()
+ var bg string
+ mustRun(t, ctx, chromedp.Evaluate(`getComputedStyle(document.documentElement).getPropertyValue("--rst-bg").trim()`, &bg))
+ return bg
+}
+
+// TestTheThemeComesBackBeforeFirstPaint: a returning visitor who chose
+// Plain sees Plain from the first frame, with the stylesheet delayed
+// half a second by the server so a page that painted before it would
+// be caught; Day's stylesheet is never requested. With Dark stored as
+// well, the first frame is dark.
+func TestTheThemeComesBackBeforeFirstPaint(t *testing.T) {
+ rig, h := siteRig(t)
+ ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
+ defer cancel()
+ open(t, ctx, rig, 1280, 900)
+ day := rootBg(t, ctx)
+ mustRun(t, ctx, chromedp.Evaluate(`localStorage.setItem("fiesta-theme", "plain"); true`, nil))
+ h.Delay("/1/fiesta-plain.css", 500*time.Millisecond)
+ onNewDocument(t, ctx, firstFrameJS)
+
+ h.Seen(true)
+ start := time.Now()
+ open(t, ctx, rig, 1280, 900)
+ var first firstFrame
+ mustRun(t, ctx, chromedp.Evaluate(`window.__first`, &first))
+ plain := rootBg(t, ctx)
+ if plain == day || plain == "" {
+ t.Fatalf("after the reload --rst-bg is %q, the same as Day's; Plain did not come back at all", plain)
+ }
+ if first.Bg != plain {
+ t.Errorf("the first frame had --rst-bg %q (sheets %v, ready %s); want Plain's %q", first.Bg, first.Sheets, first.Ready, plain)
+ }
+ if !contains(first.Sheets, "/1/fiesta-plain.css") {
+ t.Errorf("the first frame's stylesheets were %v; Plain's was not among them", first.Sheets)
+ }
+ for _, p := range h.Seen(false) {
+ if p == "/1/fiesta.css" {
+ t.Error("Day's stylesheet was requested on a visit that had Plain stored")
+ }
+ }
+ if time.Since(start) < 500*time.Millisecond {
+ t.Errorf("the reload took %s, less than the stylesheet's delay; the delay was not in the page's way", time.Since(start))
+ }
+
+ mustRun(t, ctx, chromedp.Evaluate(`localStorage.setItem("fiesta-scheme", "dark"); true`, nil))
+ open(t, ctx, rig, 1280, 900)
+ mustRun(t, ctx, chromedp.Evaluate(`window.__first`, &first))
+ if first.Scheme != "dark" || first.Theme != "dark" {
+ t.Errorf("with Dark stored the first frame's color-scheme was %q on data-theme=%q; want dark on dark", first.Scheme, first.Theme)
+ }
+ if first.Bg != plain {
+ t.Errorf("with Dark and Plain stored the first frame had --rst-bg %q; want Plain's %q", first.Bg, plain)
+ }
+ mustRun(t, ctx, chromedp.Evaluate(`localStorage.clear(); true`, nil))
+ rig.Screen("#sample", "the page in Plain, dark")
+}
+
+func contains(list []string, s string) bool {
+ for _, x := range list {
+ if x == s {
+ return true
+ }
+ }
+ return false
+}
+
+// throwingStorage is a localStorage that throws on every touch, as a
+// browser with storage blocked does.
+const throwingStorage = `Object.defineProperty(window, "localStorage", { get() { throw new Error("storage is blocked"); } });`
+
+// TestTheSchemeMenuMarksTheRestoredScheme: with Dark stored, a reload
+// marks Dark current in the scheme menu. A fresh visit, a reload after
+// choosing System, and a storage that throws each mark System.
+func TestTheSchemeMenuMarksTheRestoredScheme(t *testing.T) {
+ rig, _ := siteRig(t)
+ ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
+ defer cancel()
+ open(t, ctx, rig, 1280, 900)
+ mustRun(t, ctx, soon(".site-themebar"))
+ if got := currentScheme(t, ctx); got != "system" {
+ t.Fatalf("a fresh visit marks %q; want system", got)
+ }
+
+ mustRun(t, ctx, chromedp.Evaluate(`localStorage.setItem("fiesta-scheme", "dark"); true`, nil))
+ open(t, ctx, rig, 1280, 900)
+ mustRun(t, ctx, soon(".site-themebar"))
+ if got := currentScheme(t, ctx); got != "dark" {
+ t.Errorf("with dark stored a reload marks %q; want dark", got)
+ }
+
+ chooseScheme(t, ctx, "system")
+ open(t, ctx, rig, 1280, 900)
+ mustRun(t, ctx, soon(".site-themebar"))
+ if got := currentScheme(t, ctx); got != "system" {
+ t.Errorf("after choosing System a reload marks %q; want system", got)
+ }
+ var stored string
+ mustRun(t, ctx, chromedp.Evaluate(`localStorage.getItem("fiesta-scheme") || ""`, &stored))
+ if stored != "" {
+ t.Errorf("after choosing System the storage still holds %q", stored)
+ }
+
+ mustRun(t, ctx, chromedp.Evaluate(`localStorage.setItem("fiesta-scheme", "dark"); true`, nil))
+ onNewDocument(t, ctx, throwingStorage)
+ open(t, ctx, rig, 1280, 900)
+ mustRun(t, ctx, soon(".site-themebar"))
+ if got := currentScheme(t, ctx); got != "system" {
+ t.Errorf("with a storage that throws the page marks %q; want system", got)
+ }
+ rig.Screen("#sample", "the page with storage blocked")
+}
+
+// themeLink reads the theme stylesheet's href and the theme tab marked
+// current.
+func themeLink(t *testing.T, ctx context.Context) (href, current string) {
+ t.Helper()
+ var got struct{ Href, Current string }
+ at(t, ctx, `(() => { const l = document.getElementById("site-theme"), a = document.querySelector("[data-site-theme][aria-current]"); return JSON.stringify({Href: l ? l.getAttribute("href") : "", Current: a ? a.dataset.siteTheme : ""}); })()`, &got)
+ return got.Href, got.Current
+}
+
+// TestABrokenStoredThemeFallsBackToDay: a stored theme that is not
+// day, plain or signal, and a storage that throws, both load Day's
+// stylesheet and mark Day.
+func TestABrokenStoredThemeFallsBackToDay(t *testing.T) {
+ rig, _ := siteRig(t)
+ ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
+ defer cancel()
+ open(t, ctx, rig, 1280, 900)
+ mustRun(t, ctx, chromedp.Evaluate(`localStorage.setItem("fiesta-theme", "bogus"); true`, nil))
+ open(t, ctx, rig, 1280, 900)
+ mustRun(t, ctx, soon(".site-themebar"))
+ if href, cur := themeLink(t, ctx); href != "/1/fiesta.css" || cur != "day" {
+ t.Errorf("with bogus stored the page loads %q and marks %q; want /1/fiesta.css and day", href, cur)
+ }
+
+ mustRun(t, ctx, chromedp.Evaluate(`localStorage.setItem("fiesta-theme", "plain"); true`, nil))
+ onNewDocument(t, ctx, throwingStorage)
+ open(t, ctx, rig, 1280, 900)
+ mustRun(t, ctx, soon(".site-themebar"))
+ if href, cur := themeLink(t, ctx); href != "/1/fiesta.css" || cur != "day" {
+ t.Errorf("with a storage that throws the page loads %q and marks %q; want /1/fiesta.css and day", href, cur)
+ }
+ rig.Screen("#sample", "the page in Day")
+}
+
+// paneJS reports which sample panes and which course lists are shown,
+// and which sidebar link and course tab are current.
+const paneJS = `(() => JSON.stringify({
+ Panes: [...document.querySelectorAll("#sample .site-pane")].filter(p => p.getClientRects().length > 0).map(p => p.id),
+ Current: [...document.querySelectorAll("#sample .site-side a[aria-current]")].map(a => a.getAttribute("href")),
+ Courses: [...document.querySelectorAll("#sample .site-course")].filter(p => p.getClientRects().length > 0).map(p => p.id),
+ Tab: [...document.querySelectorAll("#sample [data-site-course][aria-current]")].map(a => a.getAttribute("href")),
+}))()`
+
+type panes struct{ Panes, Current, Courses, Tab []string }
+
+func readPanes(t *testing.T, ctx context.Context) panes {
+ t.Helper()
+ var p panes
+ at(t, ctx, paneJS, &p)
+ return p
+}
+
+// showPane presses one sidebar link of the sample.
+func showPane(t *testing.T, ctx context.Context, id string) {
+ t.Helper()
+ mustRun(t, ctx, chromedp.Click(fmt.Sprintf(`#sample .site-side a[href="#%s"]`, id), chromedp.ByQuery))
+}
+
+// TestEverySamplePaneAndCourseSwitches: the sample opens on Overview;
+// pressing Menu, then Guests, shows only that pane and marks its link;
+// each course tab shows only its course.
+func TestEverySamplePaneAndCourseSwitches(t *testing.T) {
+ rig, _ := siteRig(t)
+ ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
+ defer cancel()
+ open(t, ctx, rig, 1280, 900)
+ mustRun(t, ctx, soon(".site-themebar"))
+ if p := readPanes(t, ctx); strings.Join(p.Panes, " ") != "pane-overview" || strings.Join(p.Current, " ") != "#pane-overview" {
+ t.Fatalf("the sample opens showing %v with %v current; want pane-overview alone", p.Panes, p.Current)
+ }
+ showPane(t, ctx, "pane-menu")
+ p := readPanes(t, ctx)
+ if strings.Join(p.Panes, " ") != "pane-menu" || strings.Join(p.Current, " ") != "#pane-menu" {
+ t.Errorf("after Menu the sample shows %v with %v current; want pane-menu alone", p.Panes, p.Current)
+ }
+ if strings.Join(p.Courses, " ") != "course-starters" || strings.Join(p.Tab, " ") != "#course-starters" {
+ t.Errorf("the Menu pane opens showing courses %v with %v current; want course-starters alone", p.Courses, p.Tab)
+ }
+ for _, c := range []string{"course-mains", "course-desserts", "course-starters"} {
+ mustRun(t, ctx, chromedp.Click(fmt.Sprintf(`#sample [data-site-course][href="#%s"]`, c), chromedp.ByQuery))
+ if p := readPanes(t, ctx); strings.Join(p.Courses, " ") != c || strings.Join(p.Tab, " ") != "#"+c {
+ t.Errorf("after the %s tab the pane shows %v with %v current; want %s alone", c, p.Courses, p.Tab, c)
+ }
+ }
+ showPane(t, ctx, "pane-guests")
+ if p := readPanes(t, ctx); strings.Join(p.Panes, " ") != "pane-guests" || strings.Join(p.Current, " ") != "#pane-guests" {
+ t.Errorf("after Guests the sample shows %v with %v current; want pane-guests alone", p.Panes, p.Current)
+ }
+ var loc string
+ mustRun(t, ctx, chromedp.Location(&loc))
+ if loc != rig.Origin+"/" {
+ t.Errorf("switching panes moved the page to %s", loc)
+ }
+ rig.Screen("#pane-guests", "the Guests pane")
+}
+
+// liveText is the text of the live region inside sel.
+func liveText(t *testing.T, ctx context.Context, sel string) string {
+ t.Helper()
+ var s string
+ mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`(() => { const l = document.querySelector(%q + " .site-live"); return l ? l.textContent : ""; })()`, sel), &s))
+ return s
+}
+
+// TestTheSampleFormsStayOnThePage: submitting Settings and Search
+// changes no URL, makes no request, and announces the local message
+// in each form's live region.
+func TestTheSampleFormsStayOnThePage(t *testing.T) {
+ rig, h := siteRig(t)
+ ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
+ defer cancel()
+ open(t, ctx, rig, 1280, 900)
+ mustRun(t, ctx, soon(".site-themebar"))
+ words := catalog(t)
+ h.Seen(true)
+
+ showPane(t, ctx, "pane-settings")
+ if got := liveText(t, ctx, "#pane-settings"); got != "" {
+ t.Fatalf("the Settings live region already says %q", got)
+ }
+ mustRun(t, ctx, chromedp.Click(`#pane-settings button[type="submit"]`, chromedp.ByQuery), chromedp.Sleep(200*time.Millisecond))
+ if got := liveText(t, ctx, "#pane-settings"); got != words["sample.saved"] {
+ t.Errorf("after Save the live region says %q; want %q", got, words["sample.saved"])
+ }
+
+ showPane(t, ctx, "pane-guests")
+ mustRun(t, ctx, chromedp.Click(`#pane-guests input[type="search"]`, chromedp.ByQuery),
+ chromedp.SendKeys(`#pane-guests input[type="search"]`, "Ana\n", chromedp.ByQuery), chromedp.Sleep(200*time.Millisecond))
+ if got := liveText(t, ctx, "#pane-guests"); got != words["sample.searched"] {
+ t.Errorf("after a search the live region says %q; want %q", got, words["sample.searched"])
+ }
+
+ var loc string
+ mustRun(t, ctx, chromedp.Location(&loc))
+ if loc != rig.Origin+"/" {
+ t.Errorf("the submits moved the page to %s", loc)
+ }
+ if seen := h.Seen(false); len(seen) > 0 {
+ t.Errorf("the submits made %d request(s): %v", len(seen), seen)
+ }
+ var roles int
+ mustRun(t, ctx, chromedp.Evaluate(`document.querySelectorAll('#sample .site-live[role="status"]').length`, &roles))
+ if roles < 2 {
+ t.Errorf("%d live regions in the sample carry role=status; want one per form", roles)
+ }
+ rig.Screen("#pane-guests", "the Guests pane after a search")
+}
+
+// switchJS reads the sample's switch: its checkbox, the change events
+// since the page counted, the knob's translateX, its track's box and
+// the knob's travel, as check's drive reads them.
+const switchJS = `((id) => { const l = document.getElementById(id), t = l.querySelector("[rst-switch-track]"), a = getComputedStyle(t, "::after");
+ const m = new DOMMatrix(a.transform === "none" ? "" : a.transform), r = t.getBoundingClientRect();
+ return JSON.stringify({Checked: l.querySelector("input").checked, Changes: (window.changes || {})[id] || 0, Dragging: l.hasAttribute("data-rst-drag"),
+ X: m.m41, L: r.left, R: r.right, Y: r.top + r.height / 2, K: parseFloat(a.width), Run: r.width - r.height}); })`
+
+type switchReading struct {
+ Checked, Dragging bool
+ Changes int
+ X, L, R, Y, K, Run float64
+}
+
+func readSwitch(t *testing.T, ctx context.Context, id string) switchReading {
+ t.Helper()
+ var s switchReading
+ at(t, ctx, switchJS+fmt.Sprintf("(%q)", id), &s)
+ return s
+}
+
+// TestTheSampleSwitchDrags: the sample's reminders switch, on to begin
+// with, can be dragged off: the knob follows the pointer and on release
+// snaps to the off end, with one change event. check's drag journey,
+// on the landing page's own markup through the release's fiesta.js.
+func TestTheSampleSwitchDrags(t *testing.T) {
+ rig, _ := siteRig(t)
+ ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
+ defer cancel()
+ open(t, ctx, rig, 1280, 900)
+ mustRun(t, ctx, soon(".site-themebar"))
+ showPane(t, ctx, "pane-guests")
+ const id = "sample-reminders"
+ // The switch is below the fold of a 900px viewport; a press at its
+ // box would land on nothing until it is scrolled on screen.
+ mustRun(t, ctx, chromedp.Evaluate(`window.changes = {}; document.addEventListener("change", e => { const l = e.target.closest("[rst-switch]"); if (l) window.changes[l.id] = (window.changes[l.id] || 0) + 1; }); true`, nil),
+ chromedp.ScrollIntoView("#"+id, chromedp.ByQuery), chromedp.Sleep(100*time.Millisecond))
+ s := readSwitch(t, ctx, id)
+ if !s.Checked || s.Run <= 0 {
+ t.Fatalf("the switch starts checked=%v with a travel of %.1f; the drag below proves nothing", s.Checked, s.Run)
+ }
+ mouse := func(typ input.MouseType, x, y float64) chromedp.Action {
+ return input.DispatchMouseEvent(typ, x, y).WithButton(input.Left).WithClickCount(1)
+ }
+ // On, the knob rests at the inline end: 2px in, half a knob back.
+ x := s.R - 2 - s.K/2
+ mustRun(t, ctx, mouse(input.MousePressed, x, s.Y))
+ for _, o := range []float64{0.2, 0.4, 0.6, 0.8} {
+ mustRun(t, ctx, mouse(input.MouseMoved, x-o*s.Run, s.Y))
+ }
+ mid := readSwitch(t, ctx, id)
+ if !mid.Dragging || mid.X > 0.3*s.Run {
+ t.Errorf("80%% into a drag off, the knob is at %.1f (dragging %v); it should follow the pointer to about %.1f", mid.X, mid.Dragging, 0.2*s.Run)
+ }
+ mustRun(t, ctx, mouse(input.MouseReleased, x-0.8*s.Run, s.Y), chromedp.Sleep(350*time.Millisecond))
+ if s := readSwitch(t, ctx, id); s.Checked || s.Changes != 1 || s.X > 0.5 || s.Dragging {
+ t.Errorf("released past the middle: checked %v, %d change events, knob at %.1f; want off, one event, at 0", s.Checked, s.Changes, s.X)
+ }
+ rig.Screen("#pane-guests", "the Guests pane after the drag")
+}
+
+// copyJS reads the Copy button and its live region.
+const copyJS = `(() => { const b = document.querySelector(".site-copy"), l = document.querySelector("#install .site-live");
+ return JSON.stringify({Present: !!b, Shown: !!b && b.getClientRects().length > 0, Label: b ? b.textContent.trim() : "", Live: l ? l.textContent : "", Role: l ? l.getAttribute("role") : "", Selected: getSelection().toString()}); })()`
+
+type copyReading struct {
+ Present, Shown bool
+ Label, Live, Role, Selected string
+}
+
+func readCopy(t *testing.T, ctx context.Context) copyReading {
+ t.Helper()
+ var c copyReading
+ at(t, ctx, copyJS, &c)
+ return c
+}
+
+// TestCopy: a granted clipboard takes the link, twice in a row, with
+// the label reading Copied for a moment and the live region saying
+// so; a clipboard that refuses leaves the link selected and the live
+// region saying the copy failed; with no clipboard API the button
+// stays hidden; and every announcement goes through a status region.
+func TestCopy(t *testing.T) {
+ rig, _ := siteRig(t)
+ ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
+ defer cancel()
+ words := catalog(t)
+ // The clipboard API refuses a document without focus, and a
+ // headless page has none unless the browser is told it does.
+ mustRun(t, ctx, browser.GrantPermissions([]browser.PermissionType{browser.PermissionTypeClipboardReadWrite, browser.PermissionTypeClipboardSanitizedWrite}).WithOrigin(rig.Origin),
+ emulation.SetFocusEmulationEnabled(true))
+ open(t, ctx, rig, 1280, 900)
+ mustRun(t, ctx, soon(".site-copy"))
+
+ t.Run("granted", func(t *testing.T) {
+ for i := 1; i <= 2; i++ {
+ mustRun(t, ctx, chromedp.Evaluate(`navigator.clipboard.writeText("")`, nil, awaitPromise),
+ chromedp.Click(".site-copy", chromedp.ByQuery), chromedp.Sleep(200*time.Millisecond))
+ var held string
+ mustRun(t, ctx, chromedp.Evaluate(`navigator.clipboard.readText()`, &held, awaitPromise))
+ if held != installLink {
+ t.Errorf("copy %d: the clipboard holds %q; want the link tag", i, held)
+ }
+ if c := readCopy(t, ctx); c.Label != words["copied"] || c.Live != words["copied"] || c.Role != "status" {
+ t.Errorf("copy %d: the label reads %q and the live region (role %q) says %q; want %q in both, in a status region", i, c.Label, c.Role, c.Live, words["copied"])
+ }
+ if i == 1 {
+ mustRun(t, ctx, chromedp.Sleep(2200*time.Millisecond))
+ if c := readCopy(t, ctx); c.Label != words["copy"] {
+ t.Errorf("two seconds on, the label reads %q; want %q back", c.Label, words["copy"])
+ }
+ }
+ }
+ })
+
+ t.Run("refused", func(t *testing.T) {
+ mustRun(t, ctx, chromedp.Evaluate(`navigator.clipboard.writeText = () => Promise.reject(new Error("refused")); getSelection().removeAllRanges(); true`, nil),
+ chromedp.Click(".site-copy", chromedp.ByQuery), chromedp.Sleep(200*time.Millisecond))
+ if c := readCopy(t, ctx); c.Live != words["copy.failed"] || c.Selected != installLink {
+ t.Errorf("after a refused copy the live region says %q and the selection is %q; want %q and the link tag", c.Live, c.Selected, words["copy.failed"])
+ }
+ })
+
+ t.Run("no clipboard API", func(t *testing.T) {
+ onNewDocument(t, ctx, `Object.defineProperty(navigator, "clipboard", { value: undefined });`)
+ open(t, ctx, rig, 1280, 900)
+ mustRun(t, ctx, soon(".site-themebar"))
+ if c := readCopy(t, ctx); !c.Present || c.Shown {
+ t.Errorf("with no clipboard API the Copy button is present=%v shown=%v; want present and hidden", c.Present, c.Shown)
+ }
+ })
+ rig.Screen("#install", "the install section")
+}
+
+// buntingJS reads the bunting: whether a canvas is there and shown,
+// whether the still SVG is shown, and the canvas's pixels as a data
+// URL, for telling two moments apart.
+const buntingJS = `(() => { const row = document.getElementById("bunting"), c = row.querySelector("canvas"), svg = row.querySelector("svg");
+ const shown = e => !!e && e.getClientRects().length > 0 && getComputedStyle(e).visibility !== "hidden";
+ return JSON.stringify({Canvas: !!c, CanvasShown: shown(c), SvgShown: shown(svg), Pixels: c ? c.toDataURL() : ""}); })()`
+
+type bunting struct {
+ Canvas, CanvasShown, SvgShown bool
+ Pixels string
+}
+
+func readBunting(t *testing.T, ctx context.Context) bunting {
+ t.Helper()
+ var b bunting
+ at(t, ctx, buntingJS, &b)
+ return b
+}
+
+// reduceMotion emulates prefers-reduced-motion: reduce when on, and
+// clears the emulation when off.
+func reduceMotion(t *testing.T, ctx context.Context, on bool) {
+ t.Helper()
+ if on {
+ mustRun(t, ctx, emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-reduced-motion", Value: "reduce"}}))
+ } else {
+ mustRun(t, ctx, emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-reduced-motion", Value: "no-preference"}}))
+ }
+}
+
+// waitBunting polls until the bunting reads as fn wants, or fails.
+func waitBunting(t *testing.T, ctx context.Context, what string, fn func(bunting) bool) bunting {
+ t.Helper()
+ deadline := time.Now().Add(10 * time.Second)
+ for {
+ b := readBunting(t, ctx)
+ if fn(b) {
+ return b
+ }
+ if !time.Now().Before(deadline) {
+ t.Fatalf("%s: never happened; canvas %v (shown %v), svg shown %v", what, b.Canvas, b.CanvasShown, b.SvgShown)
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+}
+
+// TestTheBuntingMovesOnlyWhenMotionIsAllowed: under normal motion the
+// canvas is drawn and its pixels differ across 600 ms, the control.
+// Under reduced motion there is no moving canvas and the still SVG
+// shows. Reduced motion turned on mid-visit stops the canvas and shows
+// the SVG; turned off again, the canvas comes back.
+func TestTheBuntingMovesOnlyWhenMotionIsAllowed(t *testing.T) {
+ rig, _ := siteRig(t)
+ ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
+ defer cancel()
+ defer func() {
+ rctx, rcancel := context.WithTimeout(rig.Context(), 10*time.Second)
+ defer rcancel()
+ _ = chromedp.Run(rctx, emulation.SetEmulatedMedia())
+ }()
+ moving := func(b bunting) bool { return b.Canvas && b.CanvasShown && !b.SvgShown }
+ still := func(b bunting) bool { return !b.CanvasShown && b.SvgShown }
+
+ reduceMotion(t, ctx, false)
+ open(t, ctx, rig, 1280, 900)
+ before := waitBunting(t, ctx, "the canvas under normal motion", moving)
+ mustRun(t, ctx, chromedp.Sleep(600*time.Millisecond))
+ after := readBunting(t, ctx)
+ if before.Pixels == after.Pixels {
+ t.Error("the canvas pixels are the same 600 ms apart; the bunting is not moving, so the reduced-motion check below would prove nothing")
+ }
+
+ reduceMotion(t, ctx, true)
+ open(t, ctx, rig, 1280, 900)
+ mustRun(t, ctx, chromedp.Sleep(300*time.Millisecond))
+ if b := readBunting(t, ctx); !still(b) {
+ t.Errorf("under reduced motion the canvas is there=%v shown=%v and the SVG shown=%v; want no moving canvas and the SVG", b.Canvas, b.CanvasShown, b.SvgShown)
+ }
+
+ reduceMotion(t, ctx, false)
+ open(t, ctx, rig, 1280, 900)
+ waitBunting(t, ctx, "the canvas after motion was allowed again", moving)
+ reduceMotion(t, ctx, true)
+ waitBunting(t, ctx, "the still SVG after reduced motion was turned on mid-visit", still)
+ mustRun(t, ctx, chromedp.Sleep(300*time.Millisecond))
+ if b := readBunting(t, ctx); !still(b) {
+ t.Errorf("300 ms after reduced motion came on, canvas shown=%v and the SVG shown=%v; want the SVG alone", b.CanvasShown, b.SvgShown)
+ }
+ reduceMotion(t, ctx, false)
+ waitBunting(t, ctx, "the canvas after reduced motion was turned off again", moving)
+ rig.Screen("#headline", "the page under normal motion")
+}
+
+// withoutAnchorSupport is what anchor.js's feature test sees in a
+// browser without anchor positioning: CSS.supports says no to it.
+const withoutAnchorSupport = `(() => { const s = CSS.supports.bind(CSS); CSS.supports = (...a) => !/position-area|position-try/.test(a.join(" ")) && s(...a); })()`
+
+// menuJS opens the language menu and measures its panel against the
+// viewport.
+const menuJS = `(async () => { const d = document.querySelector(".site-lang"); d.querySelector("summary").click();
+ await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
+ const p = d.querySelector(":scope > [rst-dropdown-menu]").getBoundingClientRect();
+ return JSON.stringify({Open: d.open, Left: p.left, Right: p.right, Top: p.top, Bottom: p.bottom, VW: document.documentElement.clientWidth, VH: innerHeight, Flipped: document.documentElement.hasAttribute("data-rst-flip-menus")}); })()`
+
+// TestTheLanguageMenuOpensOnAPhone: at 320px the language menu opens
+// with its panel inside the viewport, with the browser's own anchor
+// positioning and with anchor.js standing in for it.
+func TestTheLanguageMenuOpensOnAPhone(t *testing.T) {
+ for _, leg := range []struct {
+ name string
+ anchor bool
+ }{{"anchor positioning", true}, {"anchor.js", false}} {
+ t.Run(leg.name, func(t *testing.T) {
+ rig, _ := siteRig(t)
+ ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
+ defer cancel()
+ if !leg.anchor {
+ onNewDocument(t, ctx, withoutAnchorSupport)
+ }
+ mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error {
+ return emulation.SetDeviceMetricsOverride(320, 640, 1, false).Do(c)
+ }), chromedp.Navigate(rig.Origin+"/"), soon(".site-lang"))
+ var g struct {
+ Open, Flipped bool
+ Left, Right, Top, Bottom, VW, VH float64
+ }
+ at(t, ctx, menuJS, &g, awaitPromise)
+ if !g.Open {
+ t.Fatal("the language menu did not open")
+ }
+ if g.Flipped != !leg.anchor {
+ t.Errorf("anchor.js marked the root %v; want %v on this leg", g.Flipped, !leg.anchor)
+ }
+ if g.Left < -1 || g.Right > g.VW+1 || g.Top < -1 || g.Bottom > g.VH+1 {
+ t.Errorf("the language menu's panel spans %.0f…%.0f by %.0f…%.0f in a %.0fx%.0f viewport; it is off the screen", g.Left, g.Right, g.Top, g.Bottom, g.VW, g.VH)
+ }
+ rig.Screen(".site-lang", "the language menu open")
+ })
+ }
+}
diff --git a/internal/www/landing_test.go b/internal/www/landing_test.go
new file mode 100644
index 0000000..21e6953
--- /dev/null
+++ b/internal/www/landing_test.go
@@ -0,0 +1,312 @@
+package www_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "io/fs"
+ "regexp"
+ "strings"
+ "testing"
+ "testing/fstest"
+
+ xhtml "golang.org/x/net/html"
+
+ "amadan.net/rastrillo/fiesta/internal/www"
+)
+
+// landingDir is a site source holding the real landing/ files (the
+// template and every catalog) with files overlaid on top.
+func landingDir(t *testing.T, files map[string]string) fstest.MapFS {
+ t.Helper()
+ return withLanding(t, siteOf(files))
+}
+
+// withLanding copies the real landing/ files, the template and every
+// catalog, from the repository's site/ into m, under any file m
+// already has at that path. Pages needs them for the root page, so
+// every test of the tree starts from this.
+func withLanding(t *testing.T, m fstest.MapFS) fstest.MapFS {
+ t.Helper()
+ src := siteSource(t).FS()
+ err := fs.WalkDir(src, "landing", func(p string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+ if _, overlaid := m[p]; overlaid {
+ return nil
+ }
+ data, err := fs.ReadFile(src, p)
+ if err != nil {
+ return err
+ }
+ m[p] = &fstest.MapFile{Data: data}
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("reading the site's landing/ directory: %v", err)
+ }
+ return m
+}
+
+// english renders the English landing page from the real site source.
+func english(t *testing.T) []byte {
+ t.Helper()
+ out, err := www.Landing(siteSource(t).FS(), "en")
+ if err != nil {
+ t.Fatalf("landing: %v", err)
+ }
+ return out
+}
+
+// parse parses a page, failed loudly.
+func parse(t *testing.T, doc []byte) *xhtml.Node {
+ t.Helper()
+ n, err := xhtml.Parse(bytes.NewReader(doc))
+ if err != nil {
+ t.Fatalf("parsing the page: %v", err)
+ }
+ return n
+}
+
+// attr is one attribute's value, "" when absent.
+func attr(n *xhtml.Node, key string) string {
+ for _, a := range n.Attr {
+ if a.Key == key {
+ return a.Val
+ }
+ }
+ return ""
+}
+
+// walk calls fn on every element node in document order.
+func walk(n *xhtml.Node, fn func(*xhtml.Node)) {
+ if n.Type == xhtml.ElementNode {
+ fn(n)
+ }
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ walk(c, fn)
+ }
+}
+
+// text is the text under n, with the whitespace collapsed.
+func text(n *xhtml.Node) string {
+ var b strings.Builder
+ var rec func(*xhtml.Node)
+ rec = func(n *xhtml.Node) {
+ if n.Type == xhtml.TextNode {
+ b.WriteString(n.Data)
+ }
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ rec(c)
+ }
+ }
+ rec(n)
+ return strings.Join(strings.Fields(b.String()), " ")
+}
+
+// TestTheLandingKeepsTheSpecsOrder: the page's sections carry the
+// spec's ids, top to bottom in the spec's order, with the footer
+// after the reasons. Document order is what a reader without CSS
+// gets, so it is the order that matters.
+func TestTheLandingKeepsTheSpecsOrder(t *testing.T) {
+ want := []string{"top", "bunting", "headline", "install", "actions", "themes", "sample", "reasons", "footer"}
+ wanted := map[string]bool{}
+ for _, id := range want {
+ wanted[id] = true
+ }
+ var got []string
+ walk(parse(t, english(t)), func(n *xhtml.Node) {
+ if id := attr(n, "id"); wanted[id] {
+ got = append(got, id)
+ }
+ })
+ if strings.Join(got, " ") != strings.Join(want, " ") {
+ t.Errorf("the landing page's sections are %v in document order; want %v", got, want)
+ }
+}
+
+// urlRef is a quoted root-relative path inside the head's inline
+// script: the one place a URL can hide from the link and script tags.
+// The theme's URL is built from pieces there, and the piece that
+// starts with / is the one that says where it loads from.
+var urlRef = regexp.MustCompile(`["']/[^"' ]*["']`)
+
+// TestTheLandingLinksFiestaByItsPublicURLs: every stylesheet and
+// script the page loads is /1/..., the public release URL, or the
+// site's own /static/..., or inline. The design system copies Fiesta's
+// source; the landing page must not, so it is served what any user
+// is. The inline script's URLs are held to the same rule, since it is
+// what writes the theme's link.
+func TestTheLandingLinksFiestaByItsPublicURLs(t *testing.T) {
+ public := func(u string) bool {
+ return strings.HasPrefix(u, "/1/") || strings.HasPrefix(u, "/static/")
+ }
+ seen := 0
+ walk(parse(t, english(t)), func(n *xhtml.Node) {
+ switch n.Data {
+ case "link":
+ if attr(n, "rel") != "stylesheet" {
+ return
+ }
+ seen++
+ if u := attr(n, "href"); !public(u) {
+ t.Errorf("a stylesheet is loaded from %q; the landing page loads Fiesta only by its public URLs", u)
+ }
+ case "script":
+ if src := attr(n, "src"); src != "" {
+ seen++
+ if !public(src) {
+ t.Errorf("a script is loaded from %q; the landing page loads Fiesta only by its public URLs", src)
+ }
+ return
+ }
+ for _, m := range urlRef.FindAllString(text(n), -1) {
+ seen++
+ u := strings.Trim(m, `"'`)
+ if !public(u) {
+ t.Errorf("the inline script names %q; the landing page loads Fiesta only by its public URLs", u)
+ }
+ }
+ }
+ })
+ if seen < 6 {
+ t.Errorf("only %d stylesheet and script URLs were seen; the page loads at least three stylesheets and five scripts", seen)
+ }
+}
+
+// installLink is the one line a user pastes, as the install box shows
+// it: the whole URL, nothing elided.
+const installLink = `<link rel="stylesheet" href="https://fiesta.rastrillo.org/1/fiesta.css">`
+
+// TestTheLinkBoxShowsTheWholeURL: the install section holds the link
+// tag in a code element, left to right and never translated, with the
+// whole URL in its text.
+func TestTheLinkBoxShowsTheWholeURL(t *testing.T) {
+ found := false
+ walk(parse(t, english(t)), func(n *xhtml.Node) {
+ if n.Data != "code" || text(n) != installLink {
+ return
+ }
+ found = true
+ if attr(n, "dir") != "ltr" {
+ t.Errorf("the link box's code has dir=%q; want ltr, so the URL reads left to right in every language", attr(n, "dir"))
+ }
+ if attr(n, "translate") != "no" {
+ t.Errorf("the link box's code has translate=%q; want no", attr(n, "translate"))
+ }
+ inInstall := false
+ for p := n.Parent; p != nil; p = p.Parent {
+ if attr(p, "id") == "install" {
+ inInstall = true
+ }
+ }
+ if !inInstall {
+ t.Error("the link box is not inside the install section")
+ }
+ })
+ if !found {
+ t.Errorf("no code element on the page reads %s", installLink)
+ }
+}
+
+// marked renders the landing page from siteDir with every English
+// string wrapped as ⟦value⟧, so what came from the catalog can be told
+// from what did not.
+func marked(t *testing.T, siteDir fstest.MapFS) []byte {
+ t.Helper()
+ real, err := www.Strings(siteSource(t).FS(), "en")
+ if err != nil {
+ t.Fatal(err)
+ }
+ wrapped := map[string]string{}
+ for k, v := range real {
+ wrapped[k] = "⟦" + v + "⟧"
+ }
+ data, err := json.Marshal(wrapped)
+ if err != nil {
+ t.Fatal(err)
+ }
+ siteDir["landing/strings/en.json"] = &fstest.MapFile{Data: data}
+ out, err := www.Landing(siteDir, "en")
+ if err != nil {
+ t.Fatalf("landing: %v", err)
+ }
+ return out
+}
+
+// unmarkedSurfaces lists every text surface on doc that did not come
+// from the catalog: a text node with something in it, outside
+// translate="no", script, style and noscript (whose content the parser
+// keeps as text, and which holds the no-JS stylesheet link, not
+// words), that has no ⟦; and an aria-label, placeholder, title or alt
+// attribute without one.
+func unmarkedSurfaces(t *testing.T, doc []byte) []string {
+ t.Helper()
+ var out []string
+ var rec func(n *xhtml.Node, where string)
+ rec = func(n *xhtml.Node, where string) {
+ switch n.Type {
+ case xhtml.TextNode:
+ if s := strings.TrimSpace(n.Data); s != "" && !strings.Contains(s, "⟦") {
+ out = append(out, where+": text "+strconvQuote(s))
+ }
+ return
+ case xhtml.ElementNode:
+ if n.Data == "script" || n.Data == "style" || n.Data == "noscript" || attr(n, "translate") == "no" {
+ return
+ }
+ where += "/" + n.Data
+ if id := attr(n, "id"); id != "" {
+ where += "#" + id
+ }
+ for _, a := range n.Attr {
+ switch a.Key {
+ case "aria-label", "placeholder", "title", "alt":
+ if !strings.Contains(a.Val, "⟦") {
+ out = append(out, where+": "+a.Key+"="+strconvQuote(a.Val))
+ }
+ }
+ }
+ }
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ rec(c, where)
+ }
+ }
+ rec(parse(t, doc), "")
+ return out
+}
+
+func strconvQuote(s string) string {
+ if len(s) > 60 {
+ s = s[:60] + "…"
+ }
+ return `"` + s + `"`
+}
+
+// TestEveryTextSurfaceComesFromTheCatalog: rendered with a catalog
+// whose every value is wrapped in ⟦ ⟧, every text node outside
+// translate="no", script and style carries the mark, and so does every
+// aria-label, placeholder, title and alt. The control is the same
+// template with one hard-coded English word, which the check must
+// name.
+func TestEveryTextSurfaceComesFromTheCatalog(t *testing.T) {
+ if got := unmarkedSurfaces(t, marked(t, landingDir(t, nil))); len(got) > 0 {
+ t.Errorf("%d text surfaces did not come from the catalog:\n %s", len(got), strings.Join(got, "\n "))
+ }
+
+ src, err := fs.ReadFile(siteSource(t).FS(), "landing/page.html.tmpl")
+ if err != nil {
+ t.Fatal(err)
+ }
+ control := strings.Replace(string(src), "</body>", "<p>Hello</p></body>", 1)
+ if control == string(src) {
+ t.Fatal("the template has no </body> to plant the control after")
+ }
+ got := unmarkedSurfaces(t, marked(t, landingDir(t, map[string]string{"landing/page.html.tmpl": control})))
+ if len(got) != 1 || !strings.Contains(got[0], `"Hello"`) {
+ t.Errorf("the control, one hard-coded word, was reported as %v; want exactly it", got)
+ }
+}
diff --git a/internal/www/serve_test.go b/internal/www/serve_test.go
new file mode 100644
index 0000000..64bf114
--- /dev/null
+++ b/internal/www/serve_test.go
@@ -0,0 +1,227 @@
+package www_test
+
+import (
+ "io"
+ "mime"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "amadan.net/rastrillo/fiesta"
+ "amadan.net/rastrillo/fiesta/internal/site"
+ "amadan.net/rastrillo/fiesta/internal/www"
+)
+
+// siteSource is the repository's site/ directory, the real site
+// source: the landing page's template and catalogs, and static/.
+func siteSource(t *testing.T) *os.Root {
+ t.Helper()
+ r, err := os.OpenRoot("../../site")
+ if err != nil {
+ t.Fatalf("opening the site source: %v", err)
+ }
+ t.Cleanup(func() { r.Close() })
+ return r
+}
+
+// builtSite is the whole site as the edge would receive it: site.Build
+// over the committed releases and the pages www.Pages assembles from
+// the real site source.
+func builtSite(t *testing.T) map[string][]byte {
+ t.Helper()
+ pages, err := www.Pages(fiesta.FS(), siteSource(t).FS())
+ if err != nil {
+ t.Fatalf("Pages: %v", err)
+ }
+ tree, err := site.Build(os.DirFS("../../releases"), www.FS(pages))
+ if err != nil {
+ t.Fatalf("site.Build: %v", err)
+ }
+ return tree
+}
+
+// rule is one line of _redirects: a from pattern, a to path, and the
+// status. Only what the edge applies to this site is understood: a
+// from ending in /* with a :splat in to, served as a 200 rewrite.
+type rule struct {
+ from, to string
+ status int
+}
+
+// siteHandler serves a site.Build tree the way the static edge does.
+// A directory URL serves its index.html; a real file wins over any
+// rewrite; a _redirects rule with status 200 is applied as a rewrite;
+// anything else is a 404; and any method other than GET or HEAD is a
+// 405, as on the edge, so a form a page should never submit cannot
+// pass a test by accident. It records every request's path and can be
+// told to delay one path, for the first-paint drive.
+type siteHandler struct {
+ tree map[string][]byte
+ rules []rule
+
+ mu sync.Mutex
+ seen []string
+ delays map[string]time.Duration
+}
+
+func newSiteHandler(t *testing.T, tree map[string][]byte) *siteHandler {
+ t.Helper()
+ h := &siteHandler{tree: tree, delays: map[string]time.Duration{}}
+ for _, line := range strings.Split(string(tree["_redirects"]), "\n") {
+ f := strings.Fields(line)
+ if len(f) == 0 || strings.HasPrefix(f[0], "#") {
+ continue
+ }
+ if len(f) != 3 || f[2] != "200" {
+ t.Fatalf("_redirects has a rule this server does not understand: %q", line)
+ }
+ h.rules = append(h.rules, rule{from: f[0], to: f[1], status: 200})
+ }
+ return h
+}
+
+// Delay makes every response for p wait d before its body: a slow
+// stylesheet, so a drive can see what the page looks like before it
+// arrives.
+func (h *siteHandler) Delay(p string, d time.Duration) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.delays[p] = d
+}
+
+// Seen returns the paths requested so far, in order, and forgets them
+// when reset is true.
+func (h *siteHandler) Seen(reset bool) []string {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ out := append([]string(nil), h.seen...)
+ if reset {
+ h.seen = nil
+ }
+ return out
+}
+
+// file finds the tree entry for a request path: the file itself, or
+// the directory's index.
+func (h *siteHandler) file(p string) ([]byte, string, bool) {
+ name := strings.TrimPrefix(p, "/")
+ if strings.HasSuffix(p, "/") {
+ name += "index.html"
+ }
+ data, ok := h.tree[name]
+ return data, name, ok
+}
+
+// rewrite applies the first matching rule to p, or returns it as is.
+func (h *siteHandler) rewrite(p string) (string, bool) {
+ for _, r := range h.rules {
+ prefix, ok := strings.CutSuffix(r.from, "/*")
+ if !ok {
+ if p == r.from {
+ return r.to, true
+ }
+ continue
+ }
+ if rest, ok := strings.CutPrefix(p, prefix+"/"); ok {
+ return strings.Replace(r.to, ":splat", rest, 1), true
+ }
+ }
+ return p, false
+}
+
+func (h *siteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ h.mu.Lock()
+ h.seen = append(h.seen, r.URL.Path)
+ delay := h.delays[r.URL.Path]
+ h.mu.Unlock()
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ w.Header().Set("Allow", "GET, HEAD")
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ data, name, ok := h.file(r.URL.Path)
+ if !ok {
+ if to, matched := h.rewrite(r.URL.Path); matched {
+ data, name, ok = h.file(to)
+ }
+ }
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ if delay > 0 {
+ time.Sleep(delay)
+ }
+ ct := mime.TypeByExtension(path.Ext(name))
+ if ct == "" {
+ ct = "application/octet-stream"
+ }
+ w.Header().Set("Content-Type", ct)
+ w.Header().Set("Cache-Control", "no-store")
+ w.Write(data)
+}
+
+// serveSite serves tree as the edge would, on a server closed with the
+// test. The browser drives use the same handler under the harness,
+// which watches the browser as well as the server.
+func serveSite(t *testing.T, tree map[string][]byte) *httptest.Server {
+ t.Helper()
+ srv := httptest.NewServer(newSiteHandler(t, tree))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+// TestServeSiteServesLikeTheEdge holds the test server to the edge's
+// rules, since every drive's reading depends on them: a directory URL
+// serves its index.html, a /1/ URL is rewritten to the newest release
+// with a 200, a real file wins over a rewrite, anything else is a 404,
+// and a POST is a 405. A test of the test double, not of the site.
+func TestServeSiteServesLikeTheEdge(t *testing.T) {
+ srv := serveSite(t, map[string][]byte{
+ "_redirects": []byte("/1/* /1.0.0/:splat 200\n"),
+ "index.html": []byte("root"),
+ "1.0.0/fiesta.css": []byte("newest"),
+ "1/real.css": []byte("real"),
+ "design-system/x.html": []byte("page"),
+ })
+ get := func(method, p string) (int, string) {
+ t.Helper()
+ req, err := http.NewRequest(method, srv.URL+p, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ var b strings.Builder
+ if _, err := io.Copy(&b, res.Body); err != nil {
+ t.Fatal(err)
+ }
+ return res.StatusCode, b.String()
+ }
+ for _, c := range []struct {
+ method, path string
+ status int
+ body string
+ }{
+ {"GET", "/", 200, "root"},
+ {"GET", "/1/fiesta.css", 200, "newest"},
+ {"GET", "/1/real.css", 200, "real"},
+ {"GET", "/design-system/x.html", 200, "page"},
+ {"GET", "/design-system/", 404, ""},
+ {"GET", "/1/nope.css", 404, ""},
+ {"POST", "/", 405, ""},
+ } {
+ status, body := get(c.method, c.path)
+ if status != c.status || (c.body != "" && body != c.body) {
+ t.Errorf("%s %s: %d %q; want %d %q", c.method, c.path, status, body, c.status, c.body)
+ }
+ }
+}
diff --git a/internal/www/www.go b/internal/www/www.go
index d06f924..d5ba078 100644
--- a/internal/www/www.go
+++ b/internal/www/www.go
@@ -32,26 +32,6 @@ const Mount = "/design-system"
// copied to the site at the same path.
const staticDir = "static"
-// landing is the root page until the real one is built: the
-// placeholder the site went live with, in the same words.
-const landing = `<!doctype html>
-<html lang="en">
-<head>
-<meta charset="utf-8">
-<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>Fiesta</title>
-<link rel="stylesheet" href="/1/fiesta.css">
-</head>
-<body>
-<main rst-page>
-<h1>Celebrate your new design system</h1>
-<p>One stylesheet and done. JS optional. &lt;25KiB gzipped.</p>
-<pre style="white-space: pre-wrap; overflow-wrap: anywhere"><code>&lt;link rel="stylesheet" href="https://fiesta.rastrillo.org/1/fiesta.css"&gt;</code></pre>
-</main>
-</body>
-</html>
-`
-
// hidden reports whether any segment of a slash path starts with a
// dot: an editor's swap file, a Finder's .DS_Store, a stamp.
func hidden(p string) bool {
@@ -66,7 +46,8 @@ func hidden(p string) bool {
// Pages assembles the site's pages. src is fiesta.FS() or any tree
// laid out the same way, and is what the design system renders from.
// siteDir is the site source, the repository's site/ directory: its
-// static/ is copied whole, and the rest of it is the site's own
+// static/ is copied whole, its landing/ holds the landing page's
+// template and catalogs, and the rest of it is the site's own
// business, not published. A hidden file or directory anywhere in
// siteDir is refused by name rather than skipped: the directory is
// committed, so a dotfile there is a mistake someone should see.
@@ -121,7 +102,11 @@ func Pages(src fs.FS, siteDir fs.FS) (map[string][]byte, error) {
return nil, err
}
- if err := put("index.html", []byte(landing)); err != nil {
+ root, err := landing(siteDir, "en")
+ if err != nil {
+ return nil, err
+ }
+ if err := put("index.html", root); err != nil {
return nil, err
}
return out, nil
diff --git a/internal/www/www_test.go b/internal/www/www_test.go
index 44d4940..91126e7 100644
--- a/internal/www/www_test.go
+++ b/internal/www/www_test.go
@@ -26,9 +26,11 @@ func siteOf(files map[string]string) fstest.MapFS {
}
// pages runs Pages over Fiesta's own tree and siteDir, failed loudly.
+// The landing page's template and catalogs are overlaid from the real
+// site source, since Pages renders the root page from them.
func pages(t *testing.T, siteDir fstest.MapFS) map[string][]byte {
t.Helper()
- out, err := www.Pages(fiesta.FS(), siteDir)
+ out, err := www.Pages(fiesta.FS(), withLanding(t, siteDir))
if err != nil {
t.Fatalf("Pages: %v", err)
}
diff --git a/site/FOLLOW-UPS.md b/site/FOLLOW-UPS.md
index fea88a6..1bdf611 100644
--- a/site/FOLLOW-UPS.md
+++ b/site/FOLLOW-UPS.md
@@ -4,7 +4,20 @@ Fiesta is frozen while the site is built. Anything the site needs that
Fiesta lacks lives in `site/static/`, with a comment saying so, and is
listed here for Fiesta 1.1 to take in.
-- A copyable code box.
-- An embeddable sidebar.
-- Clipboard behaviour.
-- `rst-seg-tabs` for buttons.
+- A copyable code box. The landing page's install line is
+ `.site-snippet` in `site/static/site.css`: a bordered box with the
+ code `user-select: all` and a Copy button beside it.
+- An embeddable sidebar. The sample's `.site-side` is five links in a
+ column that becomes a scrolling row on a phone.
+- Clipboard behaviour. `site/static/site.js` shows Copy only where
+ `navigator.clipboard.writeText` exists, says Copied for two seconds
+ and in a status region, and on failure selects the code and says so.
+- `rst-seg-tabs` for buttons. The theme choice and the sample's course
+ tabs are anchors because anchors are what `rst-seg-tabs` styles;
+ `site.js` cancels their navigation.
+- Stats two-up on phones. Fiesta stacks `rst-stat` one per line below
+ 34rem; the sample's four sit two by two, scoped to `.site-window` in
+ `site.css`.
+- A short row status stays inline when it fits. Fiesta moves
+ `rst-status` under the row's text below 34rem; the sample's are short
+ enough to stay beside it, scoped to `.site-window` in `site.css`.
diff --git a/site/landing/page.html.tmpl b/site/landing/page.html.tmpl
new file mode 100644
index 0000000..abd301a
--- /dev/null
+++ b/site/landing/page.html.tmpl
@@ -0,0 +1,285 @@
+<!doctype html>
+<html lang="{{.Lang}}" dir="{{.Dir}}" data-rst-scheme-store="local">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{.T "title"}}</title>
+<meta name="description" content="{{.T "description"}}">
+<!-- The visitor's theme, before first paint: day, plain or signal from
+ localStorage, day for anything else or a storage that throws. One
+ parser-inserted stylesheet, which blocks rendering until it
+ arrives, so the page never paints in Day and then changes. -->
+<script>
+(function () {
+ var theme = "day";
+ try {
+ var stored = localStorage.getItem("fiesta-theme");
+ if (stored === "plain" || stored === "signal") theme = stored;
+ } catch (e) {}
+ document.write('<link rel="stylesheet" id="site-theme" href="/1/fiesta' + (theme === "day" ? "" : "-" + theme) + '.css">');
+})();
+</script>
+<noscript><link rel="stylesheet" href="/1/fiesta.css"></noscript>
+<link rel="stylesheet" href="/static/site.css">
+<script src="/1/fiesta.js"></script>
+<script defer src="/1/light.js"></script>
+<script defer src="/1/anchor.js"></script>
+<script defer src="/static/site.js"></script>
+<script defer src="/static/bunting.js"></script>
+</head>
+<body>
+<a class="site-skip" href="#main">{{.T "skip"}}</a>
+
+<header class="site-top" id="top">
+ <b class="site-brand" translate="no">Fiesta</b>
+ <nav class="site-nav" aria-label="{{.T "nav.label"}}">
+ <a href="/design-system/">{{.T "nav.design"}}</a>
+ <a href="#themes">{{.T "nav.themes"}}</a>
+ <a href="https://rastrillo.org" translate="no">Rastrillo</a>
+ <details rst-dropdown rst-locale name="rst-menus" class="site-lang">
+ <summary>{{.T "lang.label"}}<span rst-caret aria-hidden="true"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></span></summary>
+ <div rst-dropdown-menu translate="no">
+ <a href="/" lang="en" aria-current="page">English</a>
+ </div>
+ </details>
+ <a rst-btn="sm" href="#install">{{.T "nav.start"}}</a>
+ </nav>
+</header>
+
+<div class="site-bunting" id="bunting" aria-hidden="true">
+<svg class="site-flags" viewBox="0 0 2784 96" preserveAspectRatio="xMidYMin slice" focusable="false">
+ <defs>
+ <!-- One pennant, origin at the middle of its top edge: a triangle with a sewn tape along the top. -->
+ <g id="site-p0"><polygon fill="#2257df" points="-18,0 18,0 0,44"/><polygon fill="#000" opacity=".16" points="-18,0 18,0 16.7,3.2 -16.7,3.2"/><path fill="none" stroke="#fff" stroke-width=".35" stroke-dasharray=".9 .7" opacity=".45" d="M-16.9 3.6H16.9"/></g>
+ <g id="site-p1"><polygon fill="#f2b632" points="-18,0 18,0 0,44"/><polygon fill="#000" opacity=".16" points="-18,0 18,0 16.7,3.2 -16.7,3.2"/><path fill="none" stroke="#fff" stroke-width=".35" stroke-dasharray=".9 .7" opacity=".45" d="M-16.9 3.6H16.9"/></g>
+ <g id="site-p2"><polygon fill="#e8553f" points="-18,0 18,0 0,44"/><polygon fill="#000" opacity=".16" points="-18,0 18,0 16.7,3.2 -16.7,3.2"/><path fill="none" stroke="#fff" stroke-width=".35" stroke-dasharray=".9 .7" opacity=".45" d="M-16.9 3.6H16.9"/></g>
+ <g id="site-p3"><polygon fill="#16a37f" points="-18,0 18,0 0,44"/><polygon fill="#000" opacity=".16" points="-18,0 18,0 16.7,3.2 -16.7,3.2"/><path fill="none" stroke="#fff" stroke-width=".35" stroke-dasharray=".9 .7" opacity=".45" d="M-16.9 3.6H16.9"/></g>
+ <g id="site-p4"><polygon fill="#7aa2ff" points="-18,0 18,0 0,44"/><polygon fill="#000" opacity=".16" points="-18,0 18,0 16.7,3.2 -16.7,3.2"/><path fill="none" stroke="#fff" stroke-width=".35" stroke-dasharray=".9 .7" opacity=".45" d="M-16.9 3.6H16.9"/></g>
+ <g id="site-p5"><polygon fill="#e84f8a" points="-18,0 18,0 0,44"/><polygon fill="#000" opacity=".16" points="-18,0 18,0 16.7,3.2 -16.7,3.2"/><path fill="none" stroke="#fff" stroke-width=".35" stroke-dasharray=".9 .7" opacity=".45" d="M-16.9 3.6H16.9"/></g>
+ </defs>
+ <path class="site-span" d="M0 4Q48 16 96 4"/>
+ <path class="site-span" d="M96 4Q144 16 192 4"/>
+ <path class="site-span" d="M192 4Q240 16 288 4"/>
+ <path class="site-span" d="M288 4Q336 16 384 4"/>
+ <path class="site-span" d="M384 4Q432 16 480 4"/>
+ <path class="site-span" d="M480 4Q528 16 576 4"/>
+ <path class="site-span" d="M576 4Q624 16 672 4"/>
+ <path class="site-span" d="M672 4Q720 16 768 4"/>
+ <path class="site-span" d="M768 4Q816 16 864 4"/>
+ <path class="site-span" d="M864 4Q912 16 960 4"/>
+ <path class="site-span" d="M960 4Q1008 16 1056 4"/>
+ <path class="site-span" d="M1056 4Q1104 16 1152 4"/>
+ <path class="site-span" d="M1152 4Q1200 16 1248 4"/>
+ <path class="site-span" d="M1248 4Q1296 16 1344 4"/>
+ <path class="site-span" d="M1344 4Q1392 16 1440 4"/>
+ <path class="site-span" d="M1440 4Q1488 16 1536 4"/>
+ <path class="site-span" d="M1536 4Q1584 16 1632 4"/>
+ <path class="site-span" d="M1632 4Q1680 16 1728 4"/>
+ <path class="site-span" d="M1728 4Q1776 16 1824 4"/>
+ <path class="site-span" d="M1824 4Q1872 16 1920 4"/>
+ <path class="site-span" d="M1920 4Q1968 16 2016 4"/>
+ <path class="site-span" d="M2016 4Q2064 16 2112 4"/>
+ <path class="site-span" d="M2112 4Q2160 16 2208 4"/>
+ <path class="site-span" d="M2208 4Q2256 16 2304 4"/>
+ <path class="site-span" d="M2304 4Q2352 16 2400 4"/>
+ <path class="site-span" d="M2400 4Q2448 16 2496 4"/>
+ <path class="site-span" d="M2496 4Q2544 16 2592 4"/>
+ <path class="site-tie" d="M6 5.41L6.14 12.42M42 9.91L41.86 16.89M54 9.91L54.14 16.89M90 5.41L89.86 12.42M102 5.41L102.14 12.42M138 9.91L137.86 16.89M150 9.91L150.14 16.89M186 5.41L185.86 12.42M198 5.41L198.14 12.42M234 9.91L233.86 16.89M246 9.91L246.14 16.89M282 5.41L281.86 12.42M294 5.41L294.14 12.42M330 9.91L329.86 16.89M342 9.91L342.14 16.89M378 5.41L377.86 12.42M390 5.41L390.14 12.42M426 9.91L425.86 16.89M438 9.91L438.14 16.89M474 5.41L473.86 12.42M486 5.41L486.14 12.42M522 9.91L521.86 16.89M534 9.91L534.14 16.89M570 5.41L569.86 12.42M582 5.41L582.14 12.42M618 9.91L617.86 16.89M630 9.91L630.14 16.89M666 5.41L665.86 12.42M678 5.41L678.14 12.42M714 9.91L713.86 16.89M726 9.91L726.14 16.89M762 5.41L761.86 12.42M774 5.41L774.14 12.42M810 9.91L809.86 16.89M822 9.91L822.14 16.89M858 5.41L857.86 12.42M870 5.41L870.14 12.42M906 9.91L905.86 16.89M918 9.91L918.14 16.89M954 5.41L953.86 12.42M966 5.41L966.14 12.42M1002 9.91L1001.86 16.89M1014 9.91L1014.14 16.89M1050 5.41L1049.86 12.42M1062 5.41L1062.14 12.42M1098 9.91L1097.86 16.89M1110 9.91L1110.14 16.89M1146 5.41L1145.86 12.42M1158 5.41L1158.14 12.42M1194 9.91L1193.86 16.89M1206 9.91L1206.14 16.89M1242 5.41L1241.86 12.42M1254 5.41L1254.14 12.42M1290 9.91L1289.86 16.89M1302 9.91L1302.14 16.89M1338 5.41L1337.86 12.42M1350 5.41L1350.14 12.42M1386 9.91L1385.86 16.89M1398 9.91L1398.14 16.89M1434 5.41L1433.86 12.42M1446 5.41L1446.14 12.42M1482 9.91L1481.86 16.89M1494 9.91L1494.14 16.89M1530 5.41L1529.86 12.42M1542 5.41L1542.14 12.42M1578 9.91L1577.86 16.89M1590 9.91L1590.14 16.89M1626 5.41L1625.86 12.42M1638 5.41L1638.14 12.42M1674 9.91L1673.86 16.89M1686 9.91L1686.14 16.89M1722 5.41L1721.86 12.42M1734 5.41L1734.14 12.42M1770 9.91L1769.86 16.89M1782 9.91L1782.14 16.89M1818 5.41L1817.86 12.42M1830 5.41L1830.14 12.42M1866 9.91L1865.86 16.89M1878 9.91L1878.14 16.89M1914 5.41L1913.86 12.42M1926 5.41L1926.14 12.42M1962 9.91L1961.86 16.89M1974 9.91L1974.14 16.89M2010 5.41L2009.86 12.42M2022 5.41L2022.14 12.42M2058 9.91L2057.86 16.89M2070 9.91L2070.14 16.89M2106 5.41L2105.86 12.42M2118 5.41L2118.14 12.42M2154 9.91L2153.86 16.89M2166 9.91L2166.14 16.89M2202 5.41L2201.86 12.42M2214 5.41L2214.14 12.42M2250 9.91L2249.86 16.89M2262 9.91L2262.14 16.89M2298 5.41L2297.86 12.42M2310 5.41L2310.14 12.42M2346 9.91L2345.86 16.89M2358 9.91L2358.14 16.89M2394 5.41L2393.86 12.42M2406 5.41L2406.14 12.42M2442 9.91L2441.86 16.89M2454 9.91L2454.14 16.89M2490 5.41L2489.86 12.42M2502 5.41L2502.14 12.42M2538 9.91L2537.86 16.89M2550 9.91L2550.14 16.89M2586 5.41L2585.86 12.42"/>
+ <g class="site-flag" data-c="0" transform="translate(24 14.66) rotate(7.13)"><use href="#site-p0"/></g>
+ <g class="site-flag" data-c="1" transform="translate(72 14.66) rotate(-7.13)"><use href="#site-p1"/></g>
+ <g class="site-flag" data-c="2" transform="translate(120 14.66) rotate(7.13)"><use href="#site-p2"/></g>
+ <g class="site-flag" data-c="3" transform="translate(168 14.66) rotate(-7.13)"><use href="#site-p3"/></g>
+ <g class="site-flag" data-c="4" transform="translate(216 14.66) rotate(7.13)"><use href="#site-p4"/></g>
+ <g class="site-flag" data-c="5" transform="translate(264 14.66) rotate(-7.13)"><use href="#site-p5"/></g>
+ <g class="site-flag" data-c="0" transform="translate(312 14.66) rotate(7.13)"><use href="#site-p0"/></g>
+ <g class="site-flag" data-c="1" transform="translate(360 14.66) rotate(-7.13)"><use href="#site-p1"/></g>
+ <g class="site-flag" data-c="2" transform="translate(408 14.66) rotate(7.13)"><use href="#site-p2"/></g>
+ <g class="site-flag" data-c="3" transform="translate(456 14.66) rotate(-7.13)"><use href="#site-p3"/></g>
+ <g class="site-flag" data-c="4" transform="translate(504 14.66) rotate(7.13)"><use href="#site-p4"/></g>
+ <g class="site-flag" data-c="5" transform="translate(552 14.66) rotate(-7.13)"><use href="#site-p5"/></g>
+ <g class="site-flag" data-c="0" transform="translate(600 14.66) rotate(7.13)"><use href="#site-p0"/></g>
+ <g class="site-flag" data-c="1" transform="translate(648 14.66) rotate(-7.13)"><use href="#site-p1"/></g>
+ <g class="site-flag" data-c="2" transform="translate(696 14.66) rotate(7.13)"><use href="#site-p2"/></g>
+ <g class="site-flag" data-c="3" transform="translate(744 14.66) rotate(-7.13)"><use href="#site-p3"/></g>
+ <g class="site-flag" data-c="4" transform="translate(792 14.66) rotate(7.13)"><use href="#site-p4"/></g>
+ <g class="site-flag" data-c="5" transform="translate(840 14.66) rotate(-7.13)"><use href="#site-p5"/></g>
+ <g class="site-flag" data-c="0" transform="translate(888 14.66) rotate(7.13)"><use href="#site-p0"/></g>
+ <g class="site-flag" data-c="1" transform="translate(936 14.66) rotate(-7.13)"><use href="#site-p1"/></g>
+ <g class="site-flag" data-c="2" transform="translate(984 14.66) rotate(7.13)"><use href="#site-p2"/></g>
+ <g class="site-flag" data-c="3" transform="translate(1032 14.66) rotate(-7.13)"><use href="#site-p3"/></g>
+ <g class="site-flag" data-c="4" transform="translate(1080 14.66) rotate(7.13)"><use href="#site-p4"/></g>
+ <g class="site-flag" data-c="5" transform="translate(1128 14.66) rotate(-7.13)"><use href="#site-p5"/></g>
+ <g class="site-flag" data-c="0" transform="translate(1176 14.66) rotate(7.13)"><use href="#site-p0"/></g>
+ <g class="site-flag" data-c="1" transform="translate(1224 14.66) rotate(-7.13)"><use href="#site-p1"/></g>
+ <g class="site-flag" data-c="2" transform="translate(1272 14.66) rotate(7.13)"><use href="#site-p2"/></g>
+ <g class="site-flag" data-c="3" transform="translate(1320 14.66) rotate(-7.13)"><use href="#site-p3"/></g>
+ <g class="site-flag" data-c="4" transform="translate(1368 14.66) rotate(7.13)"><use href="#site-p4"/></g>
+ <g class="site-flag" data-c="5" transform="translate(1416 14.66) rotate(-7.13)"><use href="#site-p5"/></g>
+ <g class="site-flag" data-c="0" transform="translate(1464 14.66) rotate(7.13)"><use href="#site-p0"/></g>
+ <g class="site-flag" data-c="1" transform="translate(1512 14.66) rotate(-7.13)"><use href="#site-p1"/></g>
+ <g class="site-flag" data-c="2" transform="translate(1560 14.66) rotate(7.13)"><use href="#site-p2"/></g>
+ <g class="site-flag" data-c="3" transform="translate(1608 14.66) rotate(-7.13)"><use href="#site-p3"/></g>
+ <g class="site-flag" data-c="4" transform="translate(1656 14.66) rotate(7.13)"><use href="#site-p4"/></g>
+ <g class="site-flag" data-c="5" transform="translate(1704 14.66) rotate(-7.13)"><use href="#site-p5"/></g>
+ <g class="site-flag" data-c="0" transform="translate(1752 14.66) rotate(7.13)"><use href="#site-p0"/></g>
+ <g class="site-flag" data-c="1" transform="translate(1800 14.66) rotate(-7.13)"><use href="#site-p1"/></g>
+ <g class="site-flag" data-c="2" transform="translate(1848 14.66) rotate(7.13)"><use href="#site-p2"/></g>
+ <g class="site-flag" data-c="3" transform="translate(1896 14.66) rotate(-7.13)"><use href="#site-p3"/></g>
+ <g class="site-flag" data-c="4" transform="translate(1944 14.66) rotate(7.13)"><use href="#site-p4"/></g>
+ <g class="site-flag" data-c="5" transform="translate(1992 14.66) rotate(-7.13)"><use href="#site-p5"/></g>
+ <g class="site-flag" data-c="0" transform="translate(2040 14.66) rotate(7.13)"><use href="#site-p0"/></g>
+ <g class="site-flag" data-c="1" transform="translate(2088 14.66) rotate(-7.13)"><use href="#site-p1"/></g>
+ <g class="site-flag" data-c="2" transform="translate(2136 14.66) rotate(7.13)"><use href="#site-p2"/></g>
+ <g class="site-flag" data-c="3" transform="translate(2184 14.66) rotate(-7.13)"><use href="#site-p3"/></g>
+ <g class="site-flag" data-c="4" transform="translate(2232 14.66) rotate(7.13)"><use href="#site-p4"/></g>
+ <g class="site-flag" data-c="5" transform="translate(2280 14.66) rotate(-7.13)"><use href="#site-p5"/></g>
+ <g class="site-flag" data-c="0" transform="translate(2328 14.66) rotate(7.13)"><use href="#site-p0"/></g>
+ <g class="site-flag" data-c="1" transform="translate(2376 14.66) rotate(-7.13)"><use href="#site-p1"/></g>
+ <g class="site-flag" data-c="2" transform="translate(2424 14.66) rotate(7.13)"><use href="#site-p2"/></g>
+ <g class="site-flag" data-c="3" transform="translate(2472 14.66) rotate(-7.13)"><use href="#site-p3"/></g>
+ <g class="site-flag" data-c="4" transform="translate(2520 14.66) rotate(7.13)"><use href="#site-p4"/></g>
+ <g class="site-flag" data-c="5" transform="translate(2568 14.66) rotate(-7.13)"><use href="#site-p5"/></g>
+</svg>
+</div>
+
+<main id="main">
+<div class="site-hero">
+ <h1 id="headline">{{.T "headline"}}</h1>
+ <p class="site-lede">{{.T "lede"}}</p>
+ <div class="site-install" id="install">
+ <p class="site-inote">{{.T "install"}}</p>
+ <div class="site-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" class="site-copy" hidden data-site-copied="{{.T "copied"}}" data-site-failed="{{.T "copy.failed"}}">{{.T "copy"}}</button></div>
+ <p class="site-live" role="status"></p>
+ </div>
+ <div class="site-cta" id="actions"><a rst-btn="primary" href="/design-system/">{{.T "cta.see"}}</a><a rst-btn href="#themes">{{.T "cta.themes"}}</a></div>
+</div>
+
+<div class="site-stage" id="themes">
+ <div class="site-themebar" hidden>
+ <span class="site-themelabel" id="site-theme-label">{{.T "theme.label"}}</span>
+ <nav rst-seg-tabs aria-labelledby="site-theme-label" class="site-themetabs"><a href="#themes" data-site-theme="day" aria-current="page">{{.T "theme.day"}}</a><a href="#themes" data-site-theme="plain">{{.T "theme.plain"}}</a><a href="#themes" data-site-theme="signal">{{.T "theme.signal"}}</a></nav>
+ <details rst-dropdown rst-scheme name="rst-menus">
+ <summary>{{.T "scheme.label"}}<span rst-caret aria-hidden="true"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></span></summary>
+ <div rst-dropdown-menu role="group" aria-label="{{.T "scheme.label"}}">
+ <form method="post"><input type="hidden" name="scheme" value="light"><input type="hidden" name="return" value="/"><button type="submit">{{.T "scheme.light"}}</button></form>
+ <form method="post"><input type="hidden" name="scheme" value="dark"><input type="hidden" name="return" value="/"><button type="submit">{{.T "scheme.dark"}}</button></form>
+ <form method="post"><input type="hidden" name="scheme" value="system"><input type="hidden" name="return" value="/"><button type="submit" aria-current="true">{{.T "scheme.system"}}</button></form>
+ </div>
+ </details>
+ </div>
+ <div class="site-window" id="sample" role="region" aria-label="{{.T "sample.label"}}">
+ <div class="site-bar"><i></i><i></i><i></i></div>
+ <div class="site-body">
+ <nav class="site-side" aria-label="{{.T "sample.nav.label"}}"><a href="#pane-overview" aria-current="page">{{.T "sample.overview"}}</a><a href="#pane-guests">{{.T "sample.guests"}}</a><a href="#pane-menu">{{.T "sample.menu"}}</a><a href="#pane-music">{{.T "sample.music"}}</a><a href="#pane-settings">{{.T "sample.settings"}}</a></nav>
+ <div class="site-main">
+ <section class="site-pane" id="pane-overview">
+ <h2>{{.T "sample.overview"}}</h2>
+ <div rst-stats>
+ <div rst-stat><span rst-stat-label>{{.T "sample.guests"}}</span><span rst-stat-num translate="no">48</span><span rst-stat-delta rst-tone="positive" translate="no">+6</span></div>
+ <div rst-stat><span rst-stat-label>{{.T "sample.coming"}}</span><span rst-stat-num translate="no">31</span></div>
+ <div rst-stat><span rst-stat-label>{{.T "sample.dishes"}}</span><span rst-stat-num translate="no">12</span></div>
+ <div rst-stat><span rst-stat-label>{{.T "sample.songs"}}</span><span rst-stat-num translate="no">64</span></div>
+ </div>
+ <div rst-card><h3>{{.T "sample.next"}}</h3><p>{{.T "sample.next.body"}}</p></div>
+ </section>
+ <section class="site-pane" id="pane-guests">
+ <h2>{{.T "sample.guests"}}</h2>
+ <form class="site-search" role="search" method="get" action="#sample" data-site-done="{{.T "sample.searched"}}">
+ <div rst-search><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg><input type="search" name="q" placeholder="{{.T "sample.search"}}" aria-label="{{.T "sample.search"}}"></div>
+ <p class="site-live" role="status"></p>
+ </form>
+ <div rst-list>
+ <div rst-row><span rst-row-lead data-lead="positive" aria-hidden="true" translate="no">AR</span><span rst-row-main><a href="#" translate="no">Ana Ruiz</a><small rst-row-sub>{{.T "sample.guest.plusone"}}</small></span><span rst-status rst-tone="positive">{{.T "sample.coming"}}</span></div>
+ <div rst-row><span rst-row-lead aria-hidden="true" translate="no">TB</span><span rst-row-main><a href="#" translate="no">Tom Byrne</a><small rst-row-sub>{{.T "sample.guest.noreply"}}</small></span><span rst-status rst-tone="neutral">{{.T "sample.invited"}}</span></div>
+ <div rst-row><span rst-row-lead data-lead="positive" aria-hidden="true" translate="no">MC</span><span rst-row-main><a href="#" translate="no">Mei Chen</a><small rst-row-sub>{{.T "sample.guest.vegetarian"}}</small></span><span rst-status rst-tone="positive">{{.T "sample.coming"}}</span></div>
+ </div>
+ <label rst-switch id="sample-reminders"><input type="checkbox" name="reminders" checked><span rst-switch-track aria-hidden="true"></span> <span>{{.T "sample.reminders"}}</span></label>
+ <p class="site-acts"><button rst-btn="primary" type="button">{{.T "sample.invite"}}</button> <button rst-btn type="button">{{.T "sample.export"}}</button></p>
+ </section>
+ <section class="site-pane" id="pane-menu">
+ <h2>{{.T "sample.menu"}}</h2>
+ <nav rst-seg-tabs aria-label="{{.T "sample.courses"}}"><a href="#course-starters" data-site-course aria-current="page">{{.T "sample.starters"}}</a><a href="#course-mains" data-site-course>{{.T "sample.mains"}}</a><a href="#course-desserts" data-site-course>{{.T "sample.desserts"}}</a></nav>
+ <div rst-list class="site-course" id="course-starters">
+ <div rst-row><span rst-row-main><a href="#" translate="no">Bruschetta</a><small rst-row-sub>{{.T "sample.dish.vegetarian"}}</small></span><span class="site-price" translate="no">€6</span></div>
+ <div rst-row><span rst-row-main><a href="#" translate="no">Patatas bravas</a><small rst-row-sub>{{.T "sample.dish.glutenfree"}}</small></span><span class="site-price" translate="no">€5</span></div>
+ </div>
+ <div rst-list class="site-course" id="course-mains">
+ <div rst-row><span rst-row-main><a href="#" translate="no">Paella</a><small rst-row-sub>{{.T "sample.dish.glutenfree"}}</small></span><span class="site-price" translate="no">€14</span></div>
+ <div rst-row><span rst-row-main><a href="#" translate="no">Risotto</a><small rst-row-sub>{{.T "sample.dish.vegetarian"}}</small></span><span class="site-price" translate="no">€12</span></div>
+ </div>
+ <div rst-list class="site-course" id="course-desserts">
+ <div rst-row><span rst-row-main><a href="#" translate="no">Tiramisù</a><small rst-row-sub>{{.T "sample.dish.nuts"}}</small></span><span class="site-price" translate="no">€6</span></div>
+ <div rst-row><span rst-row-main><a href="#" translate="no">Churros</a><small rst-row-sub>{{.T "sample.dish.vegetarian"}}</small></span><span class="site-price" translate="no">€5</span></div>
+ </div>
+ </section>
+ <section class="site-pane" id="pane-music">
+ <h2>{{.T "sample.music"}}</h2>
+ <div rst-list>
+ <div rst-row><span rst-row-main><a href="#" translate="no">Dancing Queen</a><small rst-row-sub translate="no">ABBA</small></span><span rst-status rst-tone="positive">{{.T "sample.playing"}}</span><span class="site-dur" translate="no">3:51</span></div>
+ <div rst-row><span rst-row-main><a href="#" translate="no">La Bamba</a><small rst-row-sub translate="no">Ritchie Valens</small></span><span class="site-dur" translate="no">2:06</span></div>
+ <div rst-row><span rst-row-main><a href="#" translate="no">September</a><small rst-row-sub translate="no">Earth, Wind &amp; Fire</small></span><span class="site-dur" translate="no">3:35</span></div>
+ <div rst-row><span rst-row-main><a href="#" translate="no">Despacito</a><small rst-row-sub translate="no">Luis Fonsi</small></span><span class="site-dur" translate="no">3:48</span></div>
+ </div>
+ <label rst-switch><input type="checkbox" name="shuffle" checked><span rst-switch-track aria-hidden="true"></span> <span>{{.T "sample.shuffle"}}</span></label>
+ <label rst-switch><input type="checkbox" name="fade"><span rst-switch-track aria-hidden="true"></span> <span>{{.T "sample.fade"}}</span></label>
+ </section>
+ <section class="site-pane" id="pane-settings">
+ <h2>{{.T "sample.settings"}}</h2>
+ <form rst-form method="get" action="#sample" data-site-done="{{.T "sample.saved"}}">
+ <div rst-field><label rst-field-label for="party-name">{{.T "sample.partyname"}}</label><input rst-input type="text" id="party-name" name="name" value="Ana's 30th" translate="no"></div>
+ <div rst-field><label rst-field-label for="party-tz">{{.T "sample.timezone"}}</label><select rst-input id="party-tz" name="tz" translate="no"><option>Europe/Dublin</option><option selected>Europe/Madrid</option><option>America/New_York</option><option>Asia/Tokyo</option></select></div>
+ <label rst-switch><input type="checkbox" name="public" checked><span rst-switch-track aria-hidden="true"></span> <span>{{.T "sample.public"}}</span></label>
+ <label rst-switch><input type="checkbox" name="notify"><span rst-switch-track aria-hidden="true"></span> <span>{{.T "sample.email"}}</span></label>
+ <div rst-form-foot><button rst-btn="primary" type="submit">{{.T "sample.save"}}</button><button rst-btn type="reset">{{.T "sample.cancel"}}</button></div>
+ <p class="site-live" role="status"></p>
+ </form>
+ </section>
+ </div>
+ </div>
+ </div>
+</div>
+
+<div class="site-why" id="reasons">
+ <div rst-card><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg><h2>{{.T "why.link.title"}}</h2><p>{{.T "why.link.body"}}</p></div>
+ <div rst-card><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg><h2>{{.T "why.dark.title"}}</h2><p>{{.T "why.dark.body"}}</p></div>
+ <div rst-card><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg><h2>{{.T "why.a11y.title"}}</h2><p>{{.T "why.a11y.body"}}</p></div>
+ <div rst-card><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v3"/><path d="m18.36 5.64-2.12 2.12"/><path d="M21 12h-3"/><path d="M5.64 5.64l2.12 2.12"/><path d="M3 12h3"/><circle cx="12" cy="15" r="4"/></svg><h2>{{.T "why.depth.title"}}</h2><p>{{.T "why.depth.body"}}</p></div>
+ <div rst-card><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></svg><h2>{{.T "why.attrs.title"}}</h2><p><code dir="ltr" translate="no">&lt;button rst-btn="primary"&gt;</code> {{.T "why.attrs.body"}}</p></div>
+ <div rst-card><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/></svg><h2>{{.T "why.rtl.title"}}</h2><p>{{.T "why.rtl.body"}}</p></div>
+</div>
+</main>
+
+<footer class="site-foot" id="footer">
+ <dl class="site-facts">
+ <div><dt>{{.T "foot.licence"}}</dt><dd translate="no">MPL-2.0</dd></div>
+ <div><dt>{{.T "foot.version"}}</dt><dd translate="no">{{.Version}}</dd></div>
+ <div><dt>{{.T "foot.source"}}</dt><dd><a href="https://amadan.net/rastrillo/fiesta" translate="no">amadan.net/rastrillo/fiesta</a></dd></div>
+ </dl>
+ <div class="site-foot-cols">
+ <div class="site-foot-brand"><b translate="no">Fiesta</b><p>{{.T "foot.tagline"}}</p></div>
+ <nav aria-labelledby="site-fh-fiesta"><h2 id="site-fh-fiesta" translate="no">Fiesta</h2><ul>
+ <li><a href="/design-system/">{{.T "nav.design"}}</a></li>
+ <li><a href="#themes">{{.T "nav.themes"}}</a></li>
+ <li><a href="#install">{{.T "nav.start"}}</a></li>
+ <li><a href="https://amadan.net/rastrillo/fiesta">{{.T "foot.source"}}</a></li>
+ </ul></nav>
+ <nav aria-labelledby="site-fh-rastrillo"><h2 id="site-fh-rastrillo" translate="no">Rastrillo</h2><ul>
+ <li><a href="https://rastrillo.org" translate="no">rastrillo.org</a></li>
+ <li><a href="https://rastrillo.org/docs/">{{.T "foot.docs"}}</a></li>
+ <li><a href="https://amadan.net/rastrillo/rastrillo">{{.T "foot.source"}}</a></li>
+ </ul></nav>
+ <nav aria-labelledby="site-fh-carlos"><h2 id="site-fh-carlos" translate="no">CARLOS</h2><ul>
+ <li><a href="https://carlosframework.com">{{.T "foot.carlos.framework"}}</a></li>
+ <li><a href="https://carlosframework.com/platform/">{{.T "foot.carlos.platform"}}</a></li>
+ <li><a href="https://carlosframework.com/start/">{{.T "foot.carlos.start"}}</a></li>
+ <li><a href="https://carloku.com" translate="no">Carloku</a></li>
+ </ul></nav>
+ <nav aria-labelledby="site-fh-principles"><h2 id="site-fh-principles">{{.T "foot.principles"}}</h2><ul>
+ <li><a href="https://11factor.org">{{.T "foot.eleven"}}</a></li>
+ <li><a href="https://carlosframework.com/letters/">{{.T "foot.letters"}}</a></li>
+ <li><a href="https://carlosframework.com/trust/">{{.T "foot.trust"}}</a></li>
+ </ul></nav>
+ </div>
+</footer>
+</body>
+</html>
diff --git a/site/static/bunting.js b/site/static/bunting.js
new file mode 100644
index 0000000..e89e204
--- /dev/null
+++ b/site/static/bunting.js
@@ -0,0 +1,456 @@
+/* bunting.js: the landing page's bunting, hung on a canvas over the
+ still SVG in the markup. A Verlet rope in swags of six pennants,
+ taut between tacks, moved by a breeze, by gusts when the page
+ scrolls, by puffs from the pointer, and by a hand brushing the
+ cloth; shaded by tilt; one animation frame loop that sleeps when the
+ row is off screen, the tab is hidden, or nothing moves.
+
+ Each pennant is a triangle of cloth: a triangular lattice of points
+ narrowing to one at the tip. Triangles are rigid in their plane, so
+ the cloth does not stretch, but fold freely out of it; only weak
+ bending constraints hold the drape. It hangs below the rope by two
+ ties at its top corners, each a rope segment that swings; the top
+ edge between them is a sewn tape, stiffer than the cloth. The tip
+ has a quicker flutter of its own. The textures carry a plain weave.
+
+ The canvas runs only while (prefers-reduced-motion: reduce) is
+ false: when it turns on the canvas goes and the SVG shows; when it
+ turns off again the canvas comes back. The pennants' colours are the
+ SVG's own, read from its markup, and the rope's is a Fiesta token,
+ so the textures are painted again when the scheme changes. */
+(function () {
+ "use strict";
+ var row = document.getElementById("bunting"), svg = row && row.querySelector("svg");
+ if (!row || !svg || !window.matchMedia) return;
+ var reduce = matchMedia("(prefers-reduced-motion: reduce)");
+ var canvas = document.createElement("canvas");
+ canvas.setAttribute("aria-hidden", "true");
+ var ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ // World units are the SVG's viewBox units, one unit a pixel at scale
+ // 1. Pennants are PITCH apart and FW wide at the top, FH to the tip,
+ // with NY rows of points; PER of them hang between every two tacks.
+ // H is the viewBox height, TS the texture pixels per unit.
+ var NY = 7, FW = 36, FH = 44, TOPY = 4, TIE = 7, PITCH = 48, PER = 6, END = 34, SPAN = PITCH * PER + 2 * END, ROW = SPAN * 8, H = 96, TS = 4;
+ var DX = FW / (NY - 1), DY = FH / (NY - 1), DD = Math.hypot(DX / 2, DY);
+ // Cloth is a little heavier than tissue paper and bends far more easily.
+ var G = 650, DAMP = 0.955, MASS = 1.15, ITER = 5, DT = 1 / 60, VMAX = 6, ROPE_INV = 0.25, BEND = 0.1, HEM = 0.7, SLACK = 1.005, ROPE_PASSES = 6;
+ var R = 58, KC_X = 11, KC_Y = 5, KC_Z = 5.5, DRAG = 0.12, CONTACT_PAD = 12, SP_FULL = 2400, touch = null;
+ var D0 = 200, CUT = 600, KW = 1.15, TAU = 0.07, RIPPLE = 0.0007, PUFF_MS = 160, MAX_PUFFS = 6, docTop = 0, docLeft = 0;
+ var IDLE = 8000, CALM = 1500;
+ var lastInput = performance.now(), breeze = 1, motion = 1;
+ var colours = Array.prototype.map.call(svg.querySelectorAll(".site-flag"), function (g) { return +g.getAttribute("data-c"); });
+ var ncol = 6, tex = [], stringColour = "currentColor";
+ var X, Y, Z, PX, PY, PZ, INV, DEP, COL, U, V, cons, flags = [], ropeN = 0, scale = 1, offset = 0, dpr = 1;
+ var pending = null, puffs = [], lastPuff = 0, PAX = 0, PAY = 0, PAZ = 0, gust = 0, t0 = performance.now();
+
+ // One texture per colour: the pennant painted from SVG (triangle,
+ // hem tape, a stitch line), then a plain weave laid over it: fine
+ // warp and weft threads at low opacity, with a faint grain so no two
+ // inches match. The colours are the SVG's own pennants'.
+ function textures(done) {
+ var span = svg.querySelector(".site-span");
+ stringColour = (span && getComputedStyle(span).stroke) || stringColour;
+ var out = [], left = ncol, W = (FW + 4) * TS, Hh = (FH + 4) * TS;
+ for (var k = 0; k < ncol; k++) (function (k) {
+ var cloth = svg.querySelector("#site-p" + k + " polygon");
+ var fill = cloth ? getComputedStyle(cloth).fill : "currentColor";
+ var src = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="-20 -2 40 48" width="' + W + '" height="' + Hh + '">' +
+ '<polygon points="-18,0 18,0 0,' + FH + '" fill="' + fill + '"/>' +
+ '<polygon points="-18,0 18,0 16.7,3.2 -16.7,3.2" fill="#000" opacity=".16"/>' +
+ '<path d="M-16.9 3.6H16.9" stroke="#fff" stroke-width=".35" stroke-dasharray=".9 .7" opacity=".45"/></svg>';
+ var img = new Image();
+ img.onload = function () {
+ var c = document.createElement("canvas");
+ c.width = W; c.height = Hh;
+ var g = c.getContext("2d");
+ g.drawImage(img, 0, 0);
+ weave(g, W, Hh, k);
+ out[k] = c;
+ if (--left === 0) { tex = out; done(); }
+ };
+ img.src = "data:image/svg+xml," + encodeURIComponent(src);
+ })(k);
+ }
+ function weave(g, W, Hh, k) {
+ g.globalCompositeOperation = "source-atop";
+ var P = 3, x, y;
+ // Weft: one thread a row, lit on top and shaded under.
+ for (y = 0; y < Hh; y += P) {
+ g.fillStyle = "rgba(255,255,255,.055)"; g.fillRect(0, y, W, 1);
+ g.fillStyle = "rgba(0,0,0,.045)"; g.fillRect(0, y + 1, W, 1);
+ }
+ // Warp: the same across, offset by half a thread every other
+ // column so the crossings read as over-and-under rather than a grid.
+ for (x = 0; x < W; x += P) {
+ var o = ((x / P) & 1) ? 1 : 0;
+ g.fillStyle = "rgba(255,255,255,.045)"; g.fillRect(x, o, 1, Hh);
+ g.fillStyle = "rgba(0,0,0,.04)"; g.fillRect(x + 1, o, 1, Hh);
+ }
+ // Grain: a few soft slubs, seeded so each colour's cloth is its own.
+ var seed = 7919 + k * 104729;
+ function rnd() { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; }
+ for (var i = 0; i < 90; i++) {
+ var sx = rnd() * W, sy = rnd() * Hh, sw = 2 + rnd() * 10, sh = 1 + rnd() * 2;
+ g.fillStyle = rnd() < 0.5 ? "rgba(255,255,255,.05)" : "rgba(0,0,0,.04)";
+ g.fillRect(sx, sy, sw, sh);
+ }
+ g.globalCompositeOperation = "source-over";
+ }
+
+ function layout() {
+ var r = svg.getBoundingClientRect();
+ docTop = r.top + scrollY; docLeft = r.left;
+ scale = Math.max(r.width / ROW, r.height / H);
+ offset = (ROW * scale - r.width) / 2;
+ dpr = Math.min(window.devicePixelRatio || 1, 2);
+ var cr = canvas.getBoundingClientRect();
+ canvas.width = Math.round(cr.width * dpr);
+ canvas.height = Math.round(cr.height * dpr);
+ // Simulate only what is on screen, out to the nearest tack each side.
+ var w0 = offset / scale - 40, w1 = (offset + r.width) / scale + 40;
+ build(Math.max(0, Math.floor(w0 / SPAN) * SPAN), Math.min(ROW, Math.ceil(w1 / SPAN) * SPAN));
+ }
+
+ function build(a0, a1) {
+ // The rope, span by span: a tack, then for each pennant a node over
+ // each top corner and one between them, a node in each gap, and
+ // the closing tack. ties[k] = [left rope node, right rope node].
+ var rx = [], ties = {}, k, i, j, p;
+ for (var s = a0 / SPAN; s < a1 / SPAN; s++) {
+ rx.push(s * SPAN, s * SPAN + END / 2);
+ for (p = 0; p < PER; p++) {
+ k = s * PER + p;
+ var cx = s * SPAN + END + PITCH * p + PITCH / 2;
+ ties[k] = [rx.length, rx.length + 2];
+ rx.push(cx - FW / 2, cx, cx + FW / 2);
+ rx.push(p < PER - 1 ? cx + PITCH / 2 : s * SPAN + SPAN - END / 2);
+ }
+ }
+ rx.push(a1);
+ ropeN = rx.length;
+ var list = Object.keys(ties).map(Number);
+ var per = NY * (NY + 1) / 2;
+ var n = ropeN + list.length * per;
+ X = new Float32Array(n); Y = new Float32Array(n); Z = new Float32Array(n);
+ PX = new Float32Array(n); PY = new Float32Array(n); PZ = new Float32Array(n); INV = new Float32Array(n);
+ DEP = new Float32Array(n); COL = new Float32Array(n); U = new Float32Array(n); V = new Float32Array(n);
+ var c = [];
+ for (i = 0; i < ropeN; i++) {
+ X[i] = PX[i] = rx[i]; Y[i] = PY[i] = TOPY; INV[i] = rx[i] % SPAN === 0 ? 0 : ROPE_INV;
+ if (i) c.push(i - 1, i, (rx[i] - rx[i - 1]) * SLACK, 1);
+ }
+ var idx = ropeN;
+ flags = list.map(function (k) {
+ var cx = END + (k % PER) * PITCH + PITCH / 2 + Math.floor(k / PER) * SPAN, ids = [], rows = [], tris = [];
+ // Points: row j has NY - j of them, DX apart, centred.
+ for (j = 0; j < NY; j++) {
+ var nj = NY - j, w = FW - DX * j, r = [];
+ for (i = 0; i < nj; i++) {
+ var lx = -w / 2 + DX * i, ly = DY * j;
+ X[idx] = PX[idx] = cx + lx; Y[idx] = PY[idx] = TOPY + TIE + ly; INV[idx] = 1;
+ DEP[idx] = j / (NY - 1); COL[idx] = i;
+ U[idx] = (lx + 20) * TS; V[idx] = (ly + 2) * TS;
+ r.push(idx); ids.push(idx++);
+ }
+ rows.push(r);
+ }
+ // Ties: the top corners to the rope. The sewn tape along the top
+ // is stiffer than the cloth below, so the top edge stays nearly
+ // straight and the pennant does not buckle when its ties swing
+ // inward.
+ var t = ties[k];
+ c.push(t[0], rows[0][0], TIE, 1, t[1], rows[0][NY - 1], TIE, 1, rows[0][0], rows[0][NY - 1], FW, HEM);
+ for (j = 0; j < NY; j++) {
+ var rj = rows[j], nj2 = rj.length;
+ for (i = 0; i < nj2; i++) {
+ var a = rj[i];
+ if (i < nj2 - 1) c.push(a, rj[i + 1], DX, 1);
+ if (j < NY - 1) {
+ // Each point hangs on the two below it; one triangle for
+ // each pair of neighbours and the point between them
+ // underneath.
+ var rb = rows[j + 1];
+ if (i > 0) c.push(a, rb[i - 1], DD, 1);
+ if (i < rb.length) c.push(a, rb[i], DD, 1);
+ if (i < nj2 - 1) tris.push(a, rj[i + 1], rb[i]);
+ if (i > 0 && i < nj2 - 1) tris.push(rb[i - 1], rb[i], a);
+ }
+ // Bending stiffness, and not much of it: cloth, not paper.
+ if (j < NY - 2 && i > 0 && i - 1 < rows[j + 2].length) c.push(a, rows[j + 2][i - 1], 2 * DY, BEND);
+ if (i < nj2 - 2) c.push(a, rj[i + 2], 2 * DX, j ? BEND : HEM);
+ }
+ }
+ return { cx: cx, ids: ids, tris: tris, tie: [t[0], rows[0][0], t[1], rows[0][NY - 1]], c: colours[k] % ncol, seed: k * 1.7 };
+ });
+ cons = new Float32Array(c);
+ // Hang the row before the first frame, so the page never opens on a drop.
+ for (i = 0; i < 120; i++) step(i * DT);
+ }
+
+ function step(t) {
+ var dt2 = DT * DT, now = performance.now(), i, f, q;
+ var tn = now / 1000;
+ if (pending && (!pending.hold || now - lastPuff >= PUFF_MS)) {
+ pending.t = tn; lastPuff = now; puffs.push(pending); pending = null;
+ if (puffs.length > MAX_PUFFS) puffs.shift();
+ }
+ while (puffs.length && tn - puffs[0].t > 1.5) puffs.shift();
+ gust *= 0.97;
+ var p = touch && now - touch.t < 90 ? touch : null;
+ breeze += ((now - lastInput < IDLE ? 1 : 0) - breeze) * 0.02;
+ for (f = 0; f < flags.length; f++) {
+ var F = flags[f], cx = F.cx;
+ // The breeze: two slow waves travelling different ways along the
+ // row, so neighbours move together but never in lockstep.
+ var b = (0.5 + 0.5 * Math.sin(t * 0.37 + cx * 0.0021) * Math.sin(t * 0.71 - cx * 0.0037 + 1.3)) * breeze + gust;
+ var wz = 200 * b, wx = 50 * b * Math.sin(t * 0.23 + cx * 0.001);
+ puffAt(cx, tn);
+ for (q = 0; q < F.ids.length; q++) {
+ var id = F.ids[q], depth = DEP[id], dep = 0.35 + 0.65 * depth, d2 = depth * depth;
+ // The breeze ripples down the cloth; the tip, free on three
+ // sides, adds a quicker flutter of its own.
+ var flut = Math.sin(t * 7.3 + COL[id] * 0.9 + depth * 3.6 + F.seed) * d2
+ + 0.7 * Math.sin(t * 11.4 + COL[id] * 0.5 + F.seed * 2.3) * d2 * d2;
+ integrate(id, (wx * (0.4 + 0.6 * depth) + PAX * dep) / MASS, PAY * dep / MASS,
+ (wz * (0.3 + 0.7 * depth) + 130 * flut * b + PAZ * dep) / MASS, dt2);
+ if (p) contact(id, p);
+ }
+ }
+ for (i = 0; i < ropeN; i++) if (INV[i]) integrate(i, 0, 0, 30 * Math.sin(t * 0.5 + i * 0.05) / MASS, dt2);
+ for (var it = 0; it < ITER; it++) {
+ for (var k = 0; k < cons.length; k += 4) {
+ var a = cons[k], c = cons[k + 1], wa = INV[a], wc = INV[c], ws = wa + wc;
+ if (!ws) continue;
+ var ex = X[c] - X[a], ey = Y[c] - Y[a], ez = Z[c] - Z[a];
+ var d = Math.sqrt(ex * ex + ey * ey + ez * ez) || 1e-6;
+ var s = (d - cons[k + 2]) / d * cons[k + 3] / ws;
+ X[a] += ex * s * wa; Y[a] += ey * s * wa; Z[a] += ez * s * wa;
+ X[c] -= ex * s * wc; Y[c] -= ey * s * wc; Z[c] -= ez * s * wc;
+ }
+ // The rope is a taut tape, not elastic: extra passes over its own
+ // links (the first ropeN - 1 constraints) so the flags' weight
+ // cannot stretch it into a deep swag.
+ for (var r = 0; r < ROPE_PASSES; r++) for (k = 0; k < (ropeN - 1) * 4; k += 4) {
+ a = cons[k]; c = cons[k + 1]; wa = INV[a]; wc = INV[c]; ws = wa + wc;
+ if (!ws) continue;
+ ex = X[c] - X[a]; ey = Y[c] - Y[a]; ez = Z[c] - Z[a];
+ d = Math.sqrt(ex * ex + ey * ey + ez * ez) || 1e-6;
+ s = (d - cons[k + 2]) / d / ws;
+ X[a] += ex * s * wa; Y[a] += ey * s * wa; Z[a] += ez * s * wa;
+ X[c] -= ex * s * wc; Y[c] -= ey * s * wc; Z[c] -= ez * s * wc;
+ }
+ }
+ }
+
+ // How much of a puff reaches a point: soft falloff with distance (in
+ // screen pixels), eased to zero at the cut so nothing pops.
+ function reach(x, y, p) {
+ var d = Math.hypot(x - p.x, y - p.y) * scale;
+ if (d >= CUT) return 0;
+ var u = Math.max(0, (d - 0.75 * CUT) / (0.25 * CUT)), e = 1 - u * u * (3 - 2 * u);
+ return e / (1 + (d / D0) * (d / D0));
+ }
+ // The pointer's air on one pennant, summed over recent puffs into
+ // PAX/PAY/PAZ; it arrives later the further along the row the pennant
+ // is, so a ripple runs down the string.
+ function puffAt(cx, tn) {
+ PAX = PAY = PAZ = 0;
+ var sum = 0, cy = TOPY + TIE + FH / 2;
+ for (var i = 0; i < puffs.length; i++) {
+ var P = puffs[i], w = reach(cx, cy, P);
+ if (!w) continue;
+ var age = tn - P.t - Math.abs(cx - P.x) * scale * RIPPLE;
+ if (age < 0) continue;
+ var e = w * Math.exp(-age / TAU);
+ if (e < 0.005) continue;
+ var ex = cx - P.x, ey = cy - P.y, dl = Math.hypot(ex, ey) || 1, m = P.sp * KW * e;
+ PAX += ex / dl * 0.6 * m + P.vx * 0.5 * KW * e;
+ PAY += ey / dl * 0.6 * m + P.vy * 0.25 * KW * e;
+ PAZ += 0.25 * m * P.side;
+ sum += e;
+ }
+ // A pointer that never stops makes a riffle, never a steady blow.
+ if (sum > 1) { PAX /= sum; PAY /= sum; PAZ /= sum; }
+ }
+ // Contact: a hand brushing the cloth. A push on the point's velocity
+ // plus a short drag for points right under the pointer.
+ function contact(id, p) {
+ var ex = X[id] - p.x, ey = Y[id] - p.y, d2 = ex * ex + ey * ey;
+ if (d2 >= R * R) return;
+ var f = 1 - Math.sqrt(d2) / R, dt2 = DT * DT; f *= f * Math.min(1, p.sp / SP_FULL);
+ X[id] += (p.vx * KC_X * f) * dt2; Y[id] += (p.vy * KC_Y * f) * dt2;
+ Z[id] += (p.sp * KC_Z * f * p.side) * dt2;
+ if (f > 0.4) { X[id] += p.vx * DT * DRAG * f; Y[id] += p.vy * DT * DRAG * f * 0.5; }
+ }
+ function integrate(id, ax, ay, az, dt2) {
+ var vx = (X[id] - PX[id]) * DAMP, vy = (Y[id] - PY[id]) * DAMP, vz = (Z[id] - PZ[id]) * DAMP;
+ var v2 = vx * vx + vy * vy + vz * vz;
+ if (v2 > VMAX * VMAX) { var k = VMAX / Math.sqrt(v2); vx *= k; vy *= k; vz *= k; }
+ if (v2 > motion) motion = v2;
+ PX[id] = X[id]; PY[id] = Y[id]; PZ[id] = Z[id];
+ X[id] += vx + ax * dt2; Y[id] += vy + (ay + G) * dt2; Z[id] += vz + az * dt2;
+ }
+
+ // Light from the upper left, a little in front.
+ var LX = -0.35, LY = -0.55, LZ = 0.76, grow = 1.1;
+ function draw() {
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ var m = dpr * scale;
+ grow = 1 + 1 / (4 * m);
+ ctx.setTransform(m, 0, 0, m, -offset * dpr, 0);
+ ctx.strokeStyle = stringColour; ctx.lineCap = "round";
+ ctx.beginPath(); ctx.moveTo(X[0], Y[0]);
+ for (var i = 1; i < ropeN; i++) ctx.lineTo(X[i], Y[i]);
+ ctx.lineWidth = 1.3; ctx.stroke();
+ ctx.beginPath();
+ for (var f = 0; f < flags.length; f++) {
+ var t = flags[f].tie;
+ ctx.moveTo(X[t[0]], Y[t[0]]); ctx.lineTo(X[t[1]], Y[t[1]]);
+ ctx.moveTo(X[t[2]], Y[t[2]]); ctx.lineTo(X[t[3]], Y[t[3]]);
+ }
+ ctx.lineWidth = 1; ctx.stroke();
+ for (f = 0; f < flags.length; f++) drawFlag(flags[f]);
+ }
+ function drawFlag(F) {
+ var tr = F.tris, img = tex[F.c];
+ for (var k = 0; k < tr.length; k += 3) {
+ var a = tr[k], b = tr[k + 1], c = tr[k + 2];
+ // Shade by how far the triangle has turned from facing the light
+ // as it does at rest.
+ var e1x = X[b] - X[a], e1y = Y[b] - Y[a], e1z = Z[b] - Z[a];
+ var e2x = X[c] - X[a], e2y = Y[c] - Y[a], e2z = Z[c] - Z[a];
+ var nx = e1y * e2z - e1z * e2y, ny = e1z * e2x - e1x * e2z, nz = e1x * e2y - e1y * e2x;
+ var nl = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
+ tri(img, a, b, c, (Math.abs(nx * LX + ny * LY + nz * LZ) / nl) - LZ);
+ }
+ }
+ // One triangle of cloth: clipped a device pixel outside itself (or
+ // the antialiased edges leave seams), textured by the affine map that
+ // takes its three texture corners to its three points.
+ function tri(img, p0, p1, p2, shade) {
+ var u0 = U[p0], v0 = V[p0], u1 = U[p1], v1 = V[p1], u2 = U[p2], v2 = V[p2];
+ var x0 = X[p0], y0 = Y[p0], x1 = X[p1], y1 = Y[p1], x2 = X[p2], y2 = Y[p2];
+ var du1 = u1 - u0, dv1 = v1 - v0, du2 = u2 - u0, dv2 = v2 - v0, det = du1 * dv2 - du2 * dv1;
+ var A = ((x1 - x0) * dv2 - (x2 - x0) * dv1) / det, B = ((y1 - y0) * dv2 - (y2 - y0) * dv1) / det;
+ var C = ((x2 - x0) * du1 - (x1 - x0) * du2) / det, D = ((y2 - y0) * du1 - (y1 - y0) * du2) / det;
+ var gx = (x0 + x1 + x2) / 3, gy = (y0 + y1 + y2) / 3, g = grow;
+ ctx.save();
+ ctx.beginPath();
+ ctx.moveTo(gx + (x0 - gx) * g, gy + (y0 - gy) * g);
+ ctx.lineTo(gx + (x1 - gx) * g, gy + (y1 - gy) * g);
+ ctx.lineTo(gx + (x2 - gx) * g, gy + (y2 - gy) * g);
+ ctx.clip();
+ ctx.transform(A, B, C, D, x0 - A * u0 - C * v0, y0 - B * u0 - D * v0);
+ var sx = Math.max(0, Math.min(u0, u1, u2) - 3), sy = Math.max(0, Math.min(v0, v1, v2) - 3);
+ var sw = Math.min(img.width - sx, Math.max(u0, u1, u2) + 3 - sx), sh = Math.min(img.height - sy, Math.max(v0, v1, v2) + 3 - sy);
+ ctx.drawImage(img, sx, sy, sw, sh, sx, sy, sw, sh);
+ if (shade < -0.02 || shade > 0.02) {
+ ctx.globalCompositeOperation = "source-atop";
+ ctx.fillStyle = shade < 0 ? "rgba(0,0,0," + Math.min(0.42, -shade * 0.9).toFixed(3) + ")" : "rgba(255,255,255," + Math.min(0.28, shade * 0.8).toFixed(3) + ")";
+ ctx.fillRect(sx, sy, sw, sh);
+ }
+ ctx.restore();
+ }
+
+ // live is whether the canvas is on the page: false under reduced
+ // motion. running is whether the frame loop is going.
+ var live = false, running = false, onScreen = true, acc = 0, lastT = 0, drawn = false;
+ function frame(now) {
+ if (!running || !live) return;
+ if (!onScreen || document.hidden) { running = false; return; }
+ acc += Math.min((now - lastT) / 1000, 0.1); lastT = now;
+ var t = (now - t0) / 1000, n = 0;
+ motion = 0;
+ while (acc >= DT && n < 3) { step(t); acc -= DT; n++; }
+ // Still cloth and no breeze left: draw this last frame and sleep.
+ var still = n > 0 && !pending && !(touch && now - touch.t < 90) && breeze < 0.01 && gust < 0.01 && motion < 1e-4 && !(puffs.length && now / 1000 - puffs[puffs.length - 1].t < 0.6);
+ if (n && (still || now - lastInput < CALM || (drawn = !drawn))) draw();
+ if (still) { running = false; return; }
+ requestAnimationFrame(frame);
+ }
+ function start() {
+ if (running || !live || !onScreen || document.hidden || !tex.length) return;
+ running = true; lastT = performance.now(); acc = 0;
+ requestAnimationFrame(frame);
+ }
+
+ var lx = null, ly = 0, lt = 0;
+ // Any pointer move on the page sends a puff; over the row it also
+ // touches the cloth. At most one puff leaves every PUFF_MS.
+ addEventListener("pointermove", function (e) {
+ if (!live) return;
+ var wx = (e.clientX - docLeft + offset) / scale, wy = (e.clientY + scrollY - docTop) / scale, now = performance.now();
+ if (lx !== null && now > lt) {
+ var dt = (now - lt) / 1000, vx = (wx - lx) / dt, vy = (wy - ly) / dt, sp = Math.hypot(vx, vy);
+ if (sp > 2500) { vx *= 2500 / sp; vy *= 2500 / sp; sp = 2500; }
+ if (!pending || sp > pending.sp) pending = { x: wx, y: wy, vx: vx, vy: vy, sp: sp, side: vx < 0 ? -1 : 1, t: now / 1000 };
+ if (now - lastPuff < PUFF_MS) pending.hold = true; else { pending.hold = false; lastPuff = now; }
+ lastInput = now;
+ start();
+ var cr = canvas.getBoundingClientRect();
+ if (e.clientY >= cr.top - CONTACT_PAD && e.clientY <= cr.top + H * scale + CONTACT_PAD) {
+ touch = { x: wx, y: wy, vx: vx, vy: vy, sp: sp, side: vx < 0 ? -1 : 1, t: now };
+ }
+ }
+ lx = wx; ly = wy; lt = now;
+ }, { passive: true });
+ var sy = scrollY, st = performance.now();
+ addEventListener("scroll", function () {
+ var now = performance.now(), v = Math.abs(scrollY - sy) / Math.max(now - st, 8);
+ sy = scrollY; st = now;
+ gust = Math.min(1.2, gust + v * 0.25);
+ if (v > 0.05) { lastInput = now; start(); }
+ }, { passive: true });
+
+ new IntersectionObserver(function (es) { onScreen = es[0].isIntersecting; if (onScreen) start(); }).observe(row);
+ document.addEventListener("visibilitychange", start);
+ var rz;
+ addEventListener("resize", function () {
+ clearTimeout(rz);
+ rz = setTimeout(function () { if (live) { layout(); draw(); } }, 120);
+ });
+
+ // The rope's colour and the pennants' fills follow the scheme, so the
+ // textures are painted again when it changes: from the page's own
+ // menu (rst-scheme, which fiesta.js dispatches) or from the OS.
+ function repaint() {
+ if (live) textures(function () { if (live) draw(); });
+ }
+ document.addEventListener("rst-scheme", repaint);
+ matchMedia("(prefers-color-scheme: dark)").addEventListener("change", repaint);
+
+ // The canvas is hung only while motion is allowed. When the
+ // preference turns on mid-visit the canvas goes and the SVG shows;
+ // when it turns off again the canvas is hung afresh.
+ var hanging = false;
+ function hang() {
+ if (live || hanging || reduce.matches) return;
+ hanging = true;
+ textures(function () {
+ hanging = false;
+ if (live || reduce.matches) return;
+ live = true;
+ row.appendChild(canvas);
+ layout();
+ draw();
+ row.classList.add("site-bunting-live");
+ lastInput = performance.now();
+ breeze = 1;
+ start();
+ });
+ }
+ function takeDown() {
+ if (!live) return;
+ live = false;
+ running = false;
+ row.classList.remove("site-bunting-live");
+ canvas.remove();
+ }
+ reduce.addEventListener("change", function () {
+ if (reduce.matches) takeDown(); else hang();
+ });
+ hang();
+})();
diff --git a/site/static/site.css b/site/static/site.css
new file mode 100644
index 0000000..4670dcf
--- /dev/null
+++ b/site/static/site.css
@@ -0,0 +1,132 @@
+/* site.css: the landing page's own styles. Fiesta is loaded by its
+ public URL and left as it is; what the page needs beyond it lives
+ here. Every class is site- prefixed and every colour is a Fiesta
+ token, so the page follows whichever theme and scheme the reader
+ chose. The sample window's phone overrides of Fiesta's stats and
+ rows, at the end, are listed in site/FOLLOW-UPS.md for Fiesta 1.1. */
+
+/* The skip link: off screen until it has focus. */
+.site-skip { position: absolute; inset-inline-start: 0; top: 0; z-index: 10; padding: 0.5rem 0.75rem; background: var(--rst-surface); color: var(--rst-text); transform: translateY(-200%); }
+.site-skip:focus { transform: none; }
+
+/* The top bar. */
+.site-top { display: flex; align-items: center; flex-wrap: wrap; gap: 12px 22px; padding: 14px 28px; border-bottom: 1px solid var(--rst-line); font-size: 0.9rem; }
+.site-brand { font-size: 1.05rem; letter-spacing: -0.01em; margin-inline-end: auto; }
+.site-nav { display: flex; align-items: center; flex-wrap: wrap; gap: 12px 22px; }
+.site-nav > a:not([rst-btn]) { color: var(--rst-text-muted); text-decoration: none; }
+.site-nav > a:not([rst-btn]):hover { color: var(--rst-text); }
+
+/* The bunting: a still SVG for no JS and reduced motion; bunting.js
+ hangs the same pennants on a canvas over it and hides the SVG. The
+ row is clipped sideways, since the pennants run past the edges of a
+ narrow page, and open below, where they hang over the headline. */
+.site-bunting { position: relative; height: 84px; pointer-events: none; overflow-x: clip; }
+.site-flags { display: block; width: 100%; height: 66px; overflow: visible; }
+.site-bunting canvas { position: absolute; left: 0; top: 0; width: 100%; height: 112px; }
+.site-bunting-live .site-flags { visibility: hidden; }
+.site-span { fill: none; stroke: var(--rst-text-faint); stroke-width: 1.3; }
+.site-tie { fill: none; stroke: var(--rst-text-faint); stroke-width: 1; }
+
+/* The hero: headline, lede, the install line and the two buttons. */
+.site-hero { text-align: center; padding: 34px 28px 0; }
+.site-hero h1 { font-size: 2.6rem; line-height: 1.08; letter-spacing: -0.025em; margin: 0 0 14px; }
+.site-lede { font-size: 1.15rem; color: var(--rst-text-muted); margin: 0 auto 22px; max-width: 64ch; text-wrap: pretty; }
+.site-install { padding: 6px 0 0; }
+.site-inote { margin: 0 auto 10px; font-size: 0.95rem; font-weight: 400; color: var(--rst-text-muted); }
+.site-snippet { display: flex; align-items: center; gap: 10px; font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; background: var(--rst-surface); border: 1px solid var(--rst-line); border-radius: 6px; padding: 10px 12px; max-width: max-content; margin: 0 auto; }
+.site-snippet code { flex: 1; color: var(--rst-text); user-select: all; }
+.site-snippet button { flex: none; }
+.site-live { margin: 0; font-size: 0.85rem; color: var(--rst-text-muted); }
+.site-install .site-live:not(:empty) { margin-top: 8px; }
+.site-cta { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 16px; justify-content: center; }
+
+/* The stage the sample sits on, with the theme bar over its end corner. */
+.site-stage { padding: 28px 60px 36px; margin-top: 26px; background: linear-gradient(var(--rst-bg), var(--rst-surface-2)); }
+.site-themebar { display: flex; justify-content: flex-end; align-items: center; flex-wrap: wrap; gap: 14px; margin: 0 0 10px; font-size: 0.85rem; }
+.site-themebar[hidden] { display: none; }
+.site-themebar [rst-seg-tabs] { margin: 0; }
+.site-themelabel { color: var(--rst-text-muted); }
+
+/* The sample: a small app window built only from Fiesta attributes. */
+.site-window { background: var(--rst-surface); border: 1px solid var(--rst-line); border-radius: 10px; box-shadow: var(--rst-shadow-pop); overflow: hidden; text-align: start; }
+.site-bar { display: flex; gap: 6px; padding: 10px 12px; border-bottom: 1px solid var(--rst-line); }
+.site-bar i { width: 10px; height: 10px; border-radius: 50%; background: var(--rst-line-strong); opacity: 0.5; }
+.site-body { display: grid; grid-template-columns: 150px 1fr; min-height: 300px; }
+.site-side { border-inline-end: 1px solid var(--rst-line); padding: 12px 8px; display: grid; gap: 2px; align-content: start; font-size: 0.85rem; }
+.site-side a { padding: 6px 10px; border-radius: 4px; color: var(--rst-text-muted); text-decoration: none; }
+.site-side a[aria-current] { background: var(--rst-accent-soft); color: var(--rst-text); font-weight: 600; }
+.site-main { padding: 16px 18px; min-width: 0; }
+.site-pane { display: grid; gap: 12px; align-content: start; }
+.site-pane[hidden] { display: none; }
+.site-pane + .site-pane { margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--rst-line); }
+.site-pane h2 { margin: 0; font-size: 1.05rem; }
+.site-pane h3 { margin: 0 0 4px; font-size: 0.95rem; }
+.site-pane p { margin: 0; font-size: 0.85rem; color: var(--rst-text-muted); }
+.site-pane [rst-row] { font-size: 0.85rem; }
+.site-pane [rst-row-sub] { display: block; font-size: 0.78rem; color: var(--rst-text-muted); font-weight: 400; }
+.site-pane [rst-seg-tabs] { margin: 0; }
+.site-pane [rst-switch] { font-size: 0.85rem; }
+.site-course[hidden] { display: none; }
+.site-search { display: grid; gap: 8px; }
+.site-acts { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; }
+.site-dur, .site-price { color: var(--rst-text-muted); font-size: 0.8rem; font-variant-numeric: tabular-nums; }
+.site-pane [rst-form] { display: grid; gap: 10px; max-width: 420px; }
+.site-pane [rst-form-foot] { margin: 4px 0 0; }
+.site-pane [rst-card] { padding: 12px 16px; }
+
+/* The six reasons. */
+.site-why { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; padding: 28px; border-top: 1px solid var(--rst-line); }
+.site-why [rst-card] { padding: 16px; }
+.site-why h2 { margin: 8px 0 4px; font-size: 0.95rem; }
+.site-why p { margin: 0; font-size: 0.87rem; color: var(--rst-text-muted); }
+.site-why svg { width: 22px; height: 22px; stroke: var(--rst-accent); }
+
+/* The footer. */
+.site-foot { border-top: 1px solid var(--rst-line); background: var(--rst-surface-2); padding: 28px 28px 36px; font-size: 0.87rem; }
+.site-facts { display: flex; flex-wrap: wrap; gap: 6px 24px; margin: 0 0 24px; color: var(--rst-text-muted); }
+.site-facts div { display: flex; gap: 6px; }
+.site-facts dt { font-weight: 600; color: var(--rst-text); }
+.site-facts dd { margin: 0; }
+.site-foot-cols { display: grid; grid-template-columns: 1.4fr repeat(4, 1fr); gap: 24px; }
+.site-foot-brand b { font-size: 1.05rem; display: block; margin-bottom: 4px; }
+.site-foot-brand p { margin: 0; color: var(--rst-text-muted); max-width: 30ch; }
+.site-foot h2 { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--rst-text-muted); margin: 0 0 8px; }
+.site-foot ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
+.site-foot a { color: var(--rst-text-muted); text-decoration: none; }
+.site-foot a:hover { color: var(--rst-text); text-decoration: underline; }
+
+/* Narrower screens: the reasons stack, the link box wraps, the
+ footer's columns pair up. */
+@media (max-width: 760px) {
+ .site-stage { padding: 20px 12px 28px; }
+ .site-snippet { max-width: 100%; }
+ .site-snippet code { white-space: normal; overflow-wrap: anywhere; }
+ .site-why { grid-template-columns: 1fr; }
+ .site-foot { padding: 24px 14px 32px; }
+ .site-foot-cols { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .site-foot-brand { grid-column: 1 / -1; }
+}
+
+/* On a phone the window runs edge to edge, so its padding is the only
+ margin: the stage drops its inline padding, the window its side
+ borders and corners, and the sidebar becomes a row across the top. */
+@media (max-width: 560px) {
+ .site-stage { padding-inline: 0; }
+ .site-themebar { padding-inline: 14px; }
+ .site-window { border-inline: 0; border-radius: 0; }
+ .site-body { grid-template-columns: minmax(0, 1fr); }
+ .site-side { border-inline-end: 0; border-bottom: 1px solid var(--rst-line); grid-auto-flow: column; overflow-x: auto; }
+ .site-main { padding: 16px 14px; }
+ /* Fiesta stacks stats one per line here; the sample's four fit two
+ by two. Scoped to the sample; a Fiesta 1.1 follow-up. */
+ .site-window [rst-stats] { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .site-window [rst-stat] { padding: 12px 14px; }
+ .site-window [rst-stat]:nth-child(odd) { border-inline-start: 0; }
+ .site-window [rst-stat]:nth-child(n+3) { border-top: 1px solid var(--rst-line); }
+ /* Fiesta moves a row's status under its text below 34rem; the
+ sample's are short enough to stay beside it. Scoped to the sample;
+ a Fiesta 1.1 follow-up. */
+ .site-window [rst-row]:has(> [rst-status]) { display: flex; flex-wrap: nowrap; align-items: center; }
+ .site-window [rst-row]:has(> [rst-status]) > [rst-row-main] { flex: 1 1 auto; min-width: 0; }
+ .site-window [rst-row]:has(> [rst-status]) > [rst-status] { font-size: var(--rst-fs-xs); padding-block: 0.1rem; flex: none; }
+}
diff --git a/site/static/site.js b/site/static/site.js
new file mode 100644
index 0000000..a877454
--- /dev/null
+++ b/site/static/site.js
@@ -0,0 +1,115 @@
+/* site.js: the landing page's own behaviour. Everything here enhances
+ markup that already works without it: the theme bar is hidden until
+ this runs, the sample's panes all show and its forms reload the page
+ at the sample, and the Copy button is hidden.
+
+ Theme. The three theme tabs swap the stylesheet the head wrote,
+ remember the choice in localStorage["fiesta-theme"], and mark the
+ current one. The scheme is fiesta.js's, through the scheme menu.
+
+ Sample. One pane shows at a time, chosen from the sidebar; the Menu
+ pane shows one course at a time, chosen from its tabs. Its two forms
+ never leave the page: a submit shows the form's own local message in
+ a status region instead.
+
+ Copy. Shown only where the clipboard API is there. A copy that works
+ says Copied for two seconds, in the label and the status region; one
+ that fails selects the link so it can be copied by hand, and says so.
+
+ The clipboard behaviour and the copyable code box are listed in
+ site/FOLLOW-UPS.md for Fiesta 1.1. */
+(function () {
+ "use strict";
+
+ var THEMES = { day: "/1/fiesta.css", plain: "/1/fiesta-plain.css", signal: "/1/fiesta-signal.css" };
+ function stored() {
+ try { return localStorage.getItem("fiesta-theme"); } catch (e) { return null; }
+ }
+ function remember(theme) {
+ try { localStorage.setItem("fiesta-theme", theme); } catch (e) {}
+ }
+ function markTheme(theme) {
+ document.querySelectorAll("[data-site-theme]").forEach(function (a) {
+ if (a.getAttribute("data-site-theme") === theme) a.setAttribute("aria-current", "page");
+ else a.removeAttribute("aria-current");
+ });
+ }
+
+ var bar = document.querySelector(".site-themebar"), link = document.getElementById("site-theme");
+ if (bar && link) {
+ var theme = stored();
+ if (!THEMES[theme]) theme = "day";
+ markTheme(theme);
+ bar.hidden = false;
+ bar.addEventListener("click", function (e) {
+ var a = e.target.closest("[data-site-theme]");
+ if (!a) return;
+ e.preventDefault();
+ var t = a.getAttribute("data-site-theme");
+ if (!THEMES[t]) return;
+ if (link.getAttribute("href") !== THEMES[t]) link.setAttribute("href", THEMES[t]);
+ remember(t);
+ markTheme(t);
+ });
+ }
+
+ var sample = document.getElementById("sample");
+ if (sample) {
+ // One group of choosers and the things they show, where each
+ // chooser links #id and the thing is the element with that id.
+ function group(links, things) {
+ function show(id) {
+ things.forEach(function (el) { el.hidden = el.id !== id; });
+ links.forEach(function (a) {
+ if (a.getAttribute("href") === "#" + id) a.setAttribute("aria-current", "page");
+ else a.removeAttribute("aria-current");
+ });
+ }
+ var current = null;
+ links.forEach(function (a) { if (a.hasAttribute("aria-current")) current = a; });
+ show((current || links[0]).getAttribute("href").slice(1));
+ links.forEach(function (a) {
+ a.addEventListener("click", function (e) {
+ e.preventDefault();
+ show(a.getAttribute("href").slice(1));
+ });
+ });
+ }
+ var side = Array.prototype.slice.call(sample.querySelectorAll('.site-side a[href^="#pane-"]'));
+ var panes = Array.prototype.slice.call(sample.querySelectorAll(".site-pane"));
+ if (side.length && panes.length) group(side, panes);
+ var tabs = Array.prototype.slice.call(sample.querySelectorAll("[data-site-course]"));
+ var courses = Array.prototype.slice.call(sample.querySelectorAll(".site-course"));
+ if (tabs.length && courses.length) group(tabs, courses);
+
+ sample.addEventListener("submit", function (e) {
+ var form = e.target, done = form.getAttribute("data-site-done"), live = form.querySelector(".site-live");
+ if (!done || !live) return;
+ e.preventDefault();
+ live.textContent = "";
+ live.textContent = done;
+ });
+ }
+
+ var copy = document.querySelector(".site-copy");
+ if (copy && navigator.clipboard && navigator.clipboard.writeText) {
+ var code = copy.parentNode.querySelector("code"), live = copy.parentNode.parentNode.querySelector(".site-live");
+ var label = copy.textContent, timer = 0;
+ copy.hidden = false;
+ copy.addEventListener("click", function () {
+ navigator.clipboard.writeText(code.textContent).then(function () {
+ var copied = copy.getAttribute("data-site-copied");
+ copy.textContent = copied;
+ if (live) { live.textContent = ""; live.textContent = copied; }
+ clearTimeout(timer);
+ timer = setTimeout(function () { copy.textContent = label; }, 2000);
+ }, function () {
+ var range = document.createRange(), selection = window.getSelection();
+ range.selectNodeContents(code);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ if (live) { live.textContent = ""; live.textContent = copy.getAttribute("data-site-failed"); }
+ });
+ });
+ }
+})();