The demo application and the modal route, at addresses of their own; the four streams held together
The demo application is four documents per theme and locale (demo.html, demo-dashboard.html, demo-requests.html, demo-request.html), built from the sidebar layout's own documents with the content slotted in and the chrome rewritten: Harbour for the brand, Dashboard and Requests in the rail with the view marked, the way up to the row just left, Ada Lovelace at the foot, the language menu to the same view in every locale. The content is Rastrillo's demo in the markup of the Fiesta examples that document each piece (demo/*.html). The Overview frames it between its intro and its routes, at the page width, with no HTML tab. The modal route is demo-modal.html, the modal example's document with a real page behind it, its tabs linking itself and Close going to the Overview. The rail's Demos gain both; the modal, shell-sidebar and shell-topbar pages link theirs in a new tab. Seams: the weighed release and the stage's scene are fields on the site, set once in newSite (started.go's weak-pointer cache goes); frame() takes the state it frames and layoutLinks names it in the language links; the language-menu rewrite is one helper, localeLinks, shared by the layouts and the demo. The wrapper line names its container in a <code> again, as Rastrillo's does, and htmlTab reads the tab from its <pre class="ds-src">. The Overview's old lead paragraph and its key go: the page is intro, demo, routes. Example pages' heads read as Rastrillo's: kicker, the example's own sentence and the two sentences every page says are one size. New, red by assertion first: TestTheDemoIsFourDocumentsInTheShippedShell, TestNoGalleryPageOpensAModalOverTheGallery, TestTheOverviewFramesTheDemo, TestTheStageSceneIsLiftedOncePerRender, TestALayoutsLanguageLinksNameTheStateItFrames; browser TestTheDemoApplicationWorksWithNoScript, TestTheDemoRequestsRowsAreClickableEdgeToEdge, TestTheDemoApplicationPassesAxe (red against a tree without the demo, each by its presence guard). Extended: TestEveryDemoLinkOpensInANewTab (the modal route, the demo and the three primitives' links; red with the demo's rail item dropped). Integration gate, preservation, already passing, each with the mutation run that turned it red: TestTheSidebarLinksGettingStartedExactlyOnce (assetNav dropping its first item), TestEveryDemoInTheRailResolves (demoHref writing demo.htm), TestEveryWidgetIsFramedAsItsKindSays (the demo's widget given an HTML panel), and TestA11yScansTheGallery, which replaces TestTheGalleryPagesPassAxe over every page kind, the Demos and the prose pages, three themes, light and dark, en and ar (.ds-lead's colour set to --rst-line; the rail's aria-label removed is not reported, landmark-unique being outside the WCAG tag set). Preservation, adapted: the page gates read pages alone through isSampleDocument (the frames and the five extra documents); TestTreeShapeIsComplete fixes the five by name; TestTheSidebarLinksEverythingOnThePageExactlyOnce counts the Demos as seven; TestAGroupedNoteNamesItsStateApart reads the label and note in the page's locale; TestTheHTMLTabNeverCarriesTheWrapper and TestCodeIsLeftToRightOnAnArabicPage read the wrapper's <code>; TestThePreviewDefaultIsMonotoneInStageWidth requires its control unconditionally; TestTheFrameMirrorsRightToLeft moves beside the bar's drives. TestTheTextOnScreenIsTheTextCopied keeps English as the Arabic expectation until the samples are translated, and says so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
29 files changed,
+1748
−272
internal/gallery/bar_browser_test.go+35 −0internal/gallery/code_browser_test.go+2 −2internal/gallery/copy.go+5 −5internal/gallery/demo.go+237 −0internal/gallery/demo/dashboard.html+23 −0internal/gallery/demo/request.html+38 −0internal/gallery/demo/requests.html+59 −0internal/gallery/demo_browser_test.go+239 −0internal/gallery/demo_test.go+387 −0internal/gallery/example_page.go+44 −5internal/gallery/frame.go+43 −20internal/gallery/frame_test.go+72 −0internal/gallery/gallery.css+6 −2internal/gallery/gallery.go+36 −5internal/gallery/gallery_test.go+8 −1internal/gallery/heights.go+40 −39internal/gallery/integration_test.go+185 −0internal/gallery/keyboard_browser_test.go+1 −1internal/gallery/modal.go+101 −0internal/gallery/nav.go+9 −14internal/gallery/overview.go+18 −7internal/gallery/pages_a11y_browser_test.go+76 −66internal/gallery/rail_test.go+35 −12internal/gallery/started.go+6 −35internal/gallery/tree_test.go+16 −6internal/gallery/view.css+1 −1internal/gallery/view.go+4 −6internal/gallery/view_browser_test.go+5 −39internal/gallery/view_test.go+17 −6
Intent
diff --git a/internal/gallery/bar_browser_test.go b/internal/gallery/bar_browser_test.go| index 9316baf..4e5aca1 100644 |
| --- a/internal/gallery/bar_browser_test.go |
| +++ b/internal/gallery/bar_browser_test.go |
| @@ -757,3 +757,38 @@ func TestASchemeChosenWithStorageRefusedIsTheOneMarked(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// TestTheFrameMirrorsRightToLeft: right to left, the shell's grid puts |
| +// the rail on the right and the bar over main on the left, with no rule |
| +// of the gallery's: the grid lines are numbered, not sided. Port of |
| +// Rastrillo's drive, on an example page of day/ar at 1280px, kept with |
| +// the bar's drives as Rastrillo keeps it. |
| +func TestTheFrameMirrorsRightToLeft(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + where := "day/ar form-layout at 1280px" |
| + openPage(t, ctx, where, pageURL(rig, "ar", "form-layout.html"), 1280, 900) |
| + var r struct { |
| + RailLeft, MainRight, BarBottom, MainTop, BarLeft, BarRight, MainLeft, BrandLeft, ControlsRight float64 |
| + } |
| + at(t, ctx, `(() => { |
| + const b = s => document.querySelector(s).getBoundingClientRect(); |
| + const rail = b("[rst-shell-rail]"), bar = b(".ds-top"), main = b("main"); |
| + const brand = b(".ds-top__brand"), controls = b(".ds-top__controls"); |
| + return JSON.stringify({RailLeft: rail.left, MainRight: main.right, BarBottom: bar.bottom, MainTop: main.top, BarLeft: bar.left, BarRight: bar.right, MainLeft: main.left, BrandLeft: brand.left, ControlsRight: controls.right}); |
| + })()`, &r) |
| + if r.RailLeft < r.MainRight-0.5 { |
| + t.Errorf("%s: the rail starts at %.1fpx, left of main's right edge %.1fpx; it belongs on the right", where, r.RailLeft, r.MainRight) |
| + } |
| + if r.BarBottom > r.MainTop+0.5 || r.BarLeft < r.MainLeft-0.5 || r.BarRight > r.MainRight+0.5 { |
| + t.Errorf("%s: the bar [%v…%v, bottom %v] is not above main [%v…%v, top %v]", where, r.BarLeft, r.BarRight, r.BarBottom, r.MainLeft, r.MainRight, r.MainTop) |
| + } |
| + // The brand is first in source order and justify-content: |
| + // space-between puts the first child at the inline start, which in |
| + // Arabic is the bar's right side, not its left: a rule keyed to |
| + // "left" rather than to the writing direction would swap these two. |
| + if r.BrandLeft < r.ControlsRight-0.5 { |
| + t.Errorf("%s: the brand's left edge is at %.1fpx, not right of the controls' right edge %.1fpx; the brand belongs at the inline start and the controls at the end", where, r.BrandLeft, r.ControlsRight) |
| + } |
| +} |
diff --git a/internal/gallery/code_browser_test.go b/internal/gallery/code_browser_test.go| index 018b451..332f9cc 100644 |
| --- a/internal/gallery/code_browser_test.go |
| +++ b/internal/gallery/code_browser_test.go |
| @@ -259,7 +259,7 @@ func TestCodeIsLeftToRightOnAnArabicPage(t *testing.T) { |
| const s = getComputedStyle(e); |
| if (s.direction !== "ltr" || !["start", "left"].includes(s.textAlign)) bad.push(sel + " " + s.direction + "/" + s.textAlign + ": " + e.textContent.slice(0, 40)); |
| }); return es.length; }; |
| - return JSON.stringify({Page: "rtl", Src: read(".ds-src"), Wrap: read(".ds-wrap .rst-mono"), Bad: bad.slice(0, 6)}); |
| + return JSON.stringify({Page: "rtl", Src: read(".ds-src"), Wrap: read(".ds-wrap code"), Bad: bad.slice(0, 6)}); |
| })()`, &got) |
| if got.Page != "rtl" { |
| t.Fatalf("%s: the page is dir=%q, so this leg reads nothing a right-to-left page does to code", where, got.Page) |
| @@ -351,7 +351,7 @@ func TestTheHTMLTabRendersExactlyLikeThePreview(t *testing.T) { |
| var jobs []job |
| serve := func(name string, e example.Entry, body string) string { |
| t.Helper() |
| - doc, err := s.frame(exampleDocument(name, e.Scripts, "", body), theme, "en", e) |
| + doc, err := s.frame(exampleDocument(name, e.Scripts, "", body), theme, "en", e, e.Default) |
| if err != nil { |
| t.Fatalf("%s: %v", name, err) |
| } |
diff --git a/internal/gallery/copy.go b/internal/gallery/copy.go| index c7430c0..b24b3d1 100644 |
| --- a/internal/gallery/copy.go |
| +++ b/internal/gallery/copy.go |
| @@ -37,8 +37,7 @@ var text = map[string]string{ |
| "gallery.kind.getting-started": "Getting started", |
| // The Overview's route into the Tokens page. |
| "gallery.route.tokens": "Every custom property the system is built out of: the theme's colour and type, and the scales for size, spacing and radius.", |
| - // The Overview: its lead, and the heading over its routes. |
| - "gallery.index.lead": "Every state of every example in the {theme} theme, each as a page of its own. Open an example to see its states live, with the markup beside each one.", |
| + // The Overview: the heading over its routes. |
| "gallery.overview.next": "Where to go next", |
| // The Overview: its intro, and its routes into Getting started and the Icons page. |
| "gallery.overview.intro": "Fiesta is Rastrillo's design system as a project of its own: consistent, polished, accessible components for any website or app, in any stack or none, with no or minimal JavaScript, in twelve languages, in clean, modern HTML and CSS. One stylesheet and done; the markup is yours to copy and remix.", |
| @@ -94,8 +93,8 @@ var text = map[string]string{ |
| "gallery.example.live": "Each example is live, but its links go nowhere.", |
| "gallery.example.html": "HTML shows the markup to copy.", |
| - // The demo application on the Overview, and the shells' open |
| - // buttons. |
| + // The demo application on the Overview, the shells' open buttons, |
| + // and the way back from the demo and the modal route. |
| "gallery.demo.heading": "The demo application", |
| "gallery.demo.lead": "A working app built only from this system: a dashboard, a list, one record open. Each has its own address. No JavaScript.", |
| "gallery.demo.open": "Open the demo application", |
| @@ -105,7 +104,8 @@ var text = map[string]string{ |
| "gallery.primitives.modal.live": "See it live at the URL it belongs to", |
| "gallery.primitives.sidebar.live": "Open the sidebar shell demo, a standalone preview.", |
| "gallery.primitives.topbar.live": "Open the topbar shell demo, a dedicated preview.", |
| - // The Overview's demo section: the callout under its lead. |
| + // The demo application's request view: the body of its closing |
| + // callout, Fiesta's own words under Rastrillo's title. |
| "gallery.demo.callout.body": "Each screen is a page of its own, plain HTML in the sidebar shell, and works the same with JavaScript off. On a phone, the sections are a list and each screen has a way back.", |
| // The modal route: the page under it and the panel over it. |
diff --git a/internal/gallery/demo.go b/internal/gallery/demo.go| new file mode 100644 |
| index 0000000..6f78df2 |
| --- /dev/null |
| +++ b/internal/gallery/demo.go |
| @@ -0,0 +1,237 @@ |
| +package gallery |
| + |
| +import ( |
| + "bytes" |
| + "embed" |
| + "fmt" |
| + "html/template" |
| + "regexp" |
| + "strings" |
| + |
| + "amadan.net/rastrillo/fiesta/example" |
| +) |
| + |
| +// The demo application. The question a first-time reader arrives with |
| +// is "what does an app built with this look like?", and every other |
| +// page of the gallery answers a smaller one, so the Overview frames an |
| +// application before it says a word about any of them: a dashboard, a |
| +// list of requests and one request open. |
| +// |
| +// Four documents, one per view, each a real page, the way the app it |
| +// stands for would serve them: demo.html is the index (on a phone the |
| +// rail is the whole page; on a desktop the dashboard sits beside it), |
| +// and demo-dashboard.html, demo-requests.html and demo-request.html are |
| +// content pages whose back control returns to demo.html#nav-<row>. No |
| +// script is needed to move around it, which the no-script drive holds. |
| +// |
| +// Rastrillo builds the four from its sidebar layout template; Fiesta's |
| +// shells are the layout examples' documents, so these are those |
| +// documents with the content slotted in (example.Slot) and the chrome |
| +// rewritten for the demo: the brand, the rail, the way up, the person, |
| +// the language menu. The content of each view is Rastrillo's demo in |
| +// the markup of the Fiesta example that documents each piece, in |
| +// demo/*.html. The documents go through the same framing as every |
| +// sample document (the gallery's assets, noindex, the scheme store, |
| +// frame.js first), with the layouts' own link rewrite skipped: the demo |
| +// marks every live link of its own data-gallery-nav. |
| + |
| +//go:embed demo/*.html |
| +var demoSource embed.FS |
| + |
| +// demoTemplates are the three views' content, by file name. |
| +var demoTemplates = template.Must(template.ParseFS(demoSource, "demo/*.html")) |
| + |
| +// demoViews are the demo's four documents, the index first. |
| +var demoViews = []string{"index", "dashboard", "requests", "request"} |
| + |
| +// demoFile is one view's file name under the theme and locale |
| +// directory: demo.html for the index, demo-<view>.html for the rest. |
| +// Each is one of reservedDocuments. |
| +func demoFile(view string) string { |
| + if view == "index" { |
| + return "demo.html" |
| + } |
| + return "demo-" + view + ".html" |
| +} |
| + |
| +// demoHref is the demo application's address: its index. |
| +func (s *site) demoHref(theme, locale string) string { |
| + return s.href(theme, locale, demoFile("index")) |
| +} |
| + |
| +// demoPageHref is one content page of the demo application. |
| +func (s *site) demoPageHref(theme, locale, view string) string { |
| + return s.href(theme, locale, demoFile(view)) |
| +} |
| + |
| +// demoData is what a view's content executes against: the three pages |
| +// it links, the Overview it leaves for, and the two sentences of the |
| +// gallery's own it carries (the way back, and the request view's |
| +// closing callout). |
| +type demoData struct { |
| + Dashboard, Requests, Request string |
| + Overview, Back, Callout string |
| +} |
| + |
| +// demoCSS is the demo's own stylesheet: one margin, which the stat band |
| +// needs above the box under it and the layout's column does not give. |
| +const demoCSS = `<style>[rst-stats] { margin-block-end: var(--rst-sp-5); }</style>` |
| + |
| +var ( |
| + // The layout's scripts the demo has no control for: no form of its |
| + // own sends (busy), and no field is enhanced (select, calendar, |
| + // datetime). |
| + demoUnused = regexp.MustCompile(`<script defer src="\.\./\.\./js/(busy|select|calendar|datetime)\.js"></script>\n?`) |
| + titleTag = regexp.MustCompile(`<title>[^<]*</title>`) |
| + // The brand, both copies, with its words. |
| + brandLink = regexp.MustCompile(`<a rst-shell-brand\b[^>]*>[^<]*</a>`) |
| + // The page's title in the shell's heading, shown when the brand is |
| + // blank. |
| + shellTitle = regexp.MustCompile(`(<h1 rst-shell-title><span>.*?</span><span>)[^<]*(</span></h1>)`) |
| + // The way up, the back strip's one link. |
| + backTag = regexp.MustCompile(`<div rst-shell-back><a\b[^>]*>`) |
| +) |
| + |
| +// demoPerson is the layout's person, as each of its two copies writes |
| +// it, and the demo's in its place. |
| +const ( |
| + layoutPerson = `<span rst-person-av aria-hidden="true">G</span><span rst-person-name>Grace Hopper</span><span rst-person-email>grace@example.com</span>` |
| + demoPerson = `<span rst-person-av aria-hidden="true">A</span><span rst-person-name>Ada Lovelace</span><span rst-person-email>ada@example.com</span>` |
| +) |
| + |
| +// demo builds one theme and locale's copy of the demo application: its |
| +// four documents, keyed by file name. |
| +func (s *site) demo(theme, locale string) (map[string][]byte, error) { |
| + data := demoData{ |
| + Dashboard: s.demoPageHref(theme, locale, "dashboard"), |
| + Requests: s.demoPageHref(theme, locale, "requests"), |
| + Request: s.demoPageHref(theme, locale, "request"), |
| + Overview: s.href(theme, locale, s.fileOf("overview")), |
| + Back: s.say(locale, "gallery.back"), |
| + Callout: s.say(locale, "gallery.demo.callout.body"), |
| + } |
| + out := make(map[string][]byte, len(demoViews)) |
| + for _, view := range demoViews { |
| + // The index is the shell's index document, the rest its page |
| + // document; the index shows the dashboard, which is what a |
| + // desktop sees beside the rail. |
| + layout, content := "layout-sidebar-page", view |
| + if view == "index" { |
| + layout, content = "layout-sidebar", "dashboard" |
| + } |
| + e, ok := s.m.Entry(layout) |
| + if !ok { |
| + return nil, fmt.Errorf("the manifest has no %s to build the demo application in", layout) |
| + } |
| + doc, err := example.Document(s.src, layout, e.Default) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var b bytes.Buffer |
| + if err := demoTemplates.ExecuteTemplate(&b, content+".html", data); err != nil { |
| + return nil, err |
| + } |
| + if doc, err = example.Slot(doc, "\n"+strings.TrimSpace(b.String())+"\n"); err != nil { |
| + return nil, fmt.Errorf("%s: %w", layout, err) |
| + } |
| + if doc, err = s.demoChrome(doc, theme, locale, view); err != nil { |
| + return nil, fmt.Errorf("%s from %s: %w", demoFile(view), layout, err) |
| + } |
| + framed, err := s.framed(doc, theme, locale, e, nil) |
| + if err != nil { |
| + return nil, fmt.Errorf("%s: %w", demoFile(view), err) |
| + } |
| + out[demoFile(view)] = []byte(framed) |
| + } |
| + return out, nil |
| +} |
| + |
| +// demoChrome rewrites the layout's chrome for one view of the demo: the |
| +// title, the scripts and the demo's one rule in the head; the brand |
| +// Harbour going to the demo's index; the page's name in the shell's |
| +// heading; a rail of Dashboard and Requests with the view's marked; the |
| +// way up to the row just left (the index's own on the index); Ada |
| +// Lovelace for the layout's person; and the language menu going to this |
| +// view in each locale. Every link it writes is marked data-gallery-nav. |
| +// A piece of chrome the layout no longer carries is an error, so a |
| +// changed layout fails the render rather than shipping a demo with the |
| +// layout's own brand in it. |
| +func (s *site) demoChrome(doc, theme, locale, view string) (string, error) { |
| + app := s.say(locale, "gallery.demos.app") |
| + home := s.demoHref(theme, locale) |
| + up, current := home+"#nav-requests", "nav-requests" |
| + switch view { |
| + case "index": |
| + up, current = home, "nav-dashboard" |
| + case "dashboard": |
| + up, current = home+"#nav-dashboard", "nav-dashboard" |
| + } |
| + var nav strings.Builder |
| + nav.WriteString("<nav rst-shell-nav>") |
| + for _, item := range []struct{ id, view, label string }{{"nav-dashboard", "dashboard", "Dashboard"}, {"nav-requests", "requests", "Requests"}} { |
| + nav.WriteString(`<a id="` + item.id + `" data-gallery-nav href="` + s.demoPageHref(theme, locale, item.view) + `"`) |
| + if item.id == current { |
| + nav.WriteString(` aria-current="page"`) |
| + } |
| + nav.WriteString(">" + item.label + "</a>") |
| + } |
| + nav.WriteString("</nav>") |
| + |
| + steps := []struct { |
| + what string |
| + re *regexp.Regexp |
| + with func(string) string |
| + }{ |
| + {"the title", titleTag, func(string) string { |
| + return "<title>" + template.HTMLEscapeString(s.say(locale, "gallery.title", "page", app, "theme", theme)) + "</title>" |
| + }}, |
| + {"the brand", brandLink, func(string) string { |
| + return `<a rst-shell-brand data-gallery-nav href="` + home + `">Harbour</a>` |
| + }}, |
| + {"the shell's heading", shellTitle, func(m string) string { |
| + sub := shellTitle.FindStringSubmatch(m) |
| + return sub[1] + template.HTMLEscapeString(app) + sub[2] |
| + }}, |
| + {"the rail", shellNav, func(string) string { return nav.String() }}, |
| + {"the way up", backTag, func(m string) string { |
| + return `<div rst-shell-back>` + liveLink(strings.TrimPrefix(m, `<div rst-shell-back>`), up) |
| + }}, |
| + } |
| + for _, step := range steps { |
| + if !step.re.MatchString(doc) { |
| + return "", fmt.Errorf("the layout has no %s to rewrite", step.what) |
| + } |
| + doc = step.re.ReplaceAllStringFunc(doc, step.with) |
| + } |
| + if !strings.Contains(doc, layoutPerson) { |
| + return "", fmt.Errorf("the layout has no person to replace") |
| + } |
| + doc = strings.ReplaceAll(doc, layoutPerson, demoPerson) |
| + if !localeMenu.MatchString(doc) { |
| + return "", fmt.Errorf("the layout has no language menu") |
| + } |
| + var err error |
| + if doc, err = s.localeLinks(doc, locale, func(l string) string { return s.demoPageHref(theme, l, view) }); err != nil { |
| + return "", err |
| + } |
| + doc = demoUnused.ReplaceAllString(doc, "") |
| + shut := strings.LastIndex(doc, "</head>") |
| + if shut < 0 { |
| + return "", fmt.Errorf("no </head> for the demo's rule") |
| + } |
| + return doc[:shut] + demoCSS + "\n" + doc[shut:], nil |
| +} |
| + |
| +// demoView is the widget the Overview frames the demo application in: |
| +// the preview widget every example uses, at the page width, loading the |
| +// real index rather than a copy of it, and with no HTML tab: an |
| +// application is not a snippet to paste. |
| +func (s *site) demoView(p page) previewView { |
| + return previewView{ |
| + Group: "demo-app-0", |
| + Style: previewStyle(p.Locale, "demo"), |
| + Class: " ds-view--page", |
| + Src: s.demoHref(p.Theme, p.Locale), |
| + Title: s.say(p.Locale, "gallery.demos.app"), |
| + } |
| +} |
diff --git a/internal/gallery/demo/dashboard.html b/internal/gallery/demo/dashboard.html| new file mode 100644 |
| index 0000000..fed96bf |
| --- /dev/null |
| +++ b/internal/gallery/demo/dashboard.html |
| @@ -0,0 +1,23 @@ |
| +{{/* The demo application's dashboard: what demo.html shows beside the |
| +rail on a desktop, and demo-dashboard.html on its own. Rastrillo's |
| +demo, written in the markup of the Fiesta examples that document each |
| +piece: page-header, stat, meter, the card rows and status-pill. */}} |
| +<section class="app-view" id="view-dashboard"> |
| +<header rst-page-header> |
| + <div rst-page-header-titles> |
| + <h1>Dashboard</h1> |
| + <p rst-page-header-sub>Everything the team has waiting this morning.</p> |
| + </div> |
| +</header> |
| +<div rst-stats><div rst-stat="lead"><span rst-stat-label>Open requests</span><span rst-stat-num>24</span><span rst-stat-delta rst-tone="positive">−6</span><span rst-stat-note>since Monday</span></div><div rst-stat><span rst-stat-label>Waiting on us</span><span rst-stat-num>6</span></div><div rst-stat><span rst-stat-label>Resolved this week</span><span rst-stat-num>41</span><span rst-stat-delta rst-tone="positive">+12</span><span rst-stat-note>since Monday</span></div></div> |
| +<div rst-box-head><h2>Mailbox storage</h2></div> |
| +<section rst-box><span rst-meter><meter rst-meter-bar value="82" min="0" max="100" aria-hidden="true"></meter><span rst-meter-num>412 / 500</span></span></section> |
| +<div rst-box-head><h2>Latest activity</h2><a rst-btn data-gallery-nav href="{{.Requests}}">Requests</a></div> |
| +<div rst-card style="--rst-cols: minmax(0, 1fr) 120px"> |
| +<div rst-lrow="head"><span>Subject</span><span class="rst-m-hide">Status</span></div> |
| +<div rst-lrow><a class="rst-nm" data-gallery-nav href="{{.Request}}">Invoice #4471 never arrived<small><bdi>Fiona Reid</bdi> · 09:12</small></a><span class="rst-m-hide"><span rst-status rst-tone="warning">Waiting</span></span></div> |
| +<div rst-lrow><a class="rst-nm" data-gallery-nav href="{{.Request}}">Card declined on renewal<small><bdi>Otto Neurath</bdi> · 08:40</small></a><span class="rst-m-hide"><span rst-status rst-tone="neutral">Open</span></span></div> |
| +<div rst-lrow><a class="rst-nm" data-gallery-nav href="{{.Request}}">Seat count is wrong on the invoice<small><bdi>Hedy Lamarr</bdi> · 11 August</small></a><span class="rst-m-hide"><span rst-status rst-tone="positive">Resolved</span></span></div> |
| +</div> |
| +<p><a rst-btn data-gallery-nav href="{{.Overview}}">{{.Back}}</a></p> |
| +</section> |
diff --git a/internal/gallery/demo/request.html b/internal/gallery/demo/request.html| new file mode 100644 |
| index 0000000..dfc8437 |
| --- /dev/null |
| +++ b/internal/gallery/demo/request.html |
| @@ -0,0 +1,38 @@ |
| +{{/* The demo application's record: demo-request.html, one request |
| +open. Rastrillo's demo, written in the markup of the Fiesta examples |
| +that document each piece: back-nav, page-header, status-pill, badge, |
| +detail-list, field-textarea, form-foot and callout. The callout's body |
| +is Fiesta's own words. */}} |
| +<section class="app-view" id="view-request"> |
| +<nav rst-back-nav><a data-gallery-nav href="{{.Requests}}">← Requests</a></nav> |
| +<header rst-page-header> |
| + <div rst-page-header-titles> |
| + <h1>Invoice #4471 never arrived</h1> |
| + <p rst-page-header-sub>Reported by Fiona Reid, and still waiting on us.</p> |
| + </div> |
| +</header> |
| +<p><span rst-status rst-tone="warning">Waiting</span> <span rst-badge>Billing</span></p> |
| +<div rst-box-head><h2>Details</h2></div> |
| +<section rst-box><dl rst-detail> |
| + <dt>Reference</dt> |
| + <dd class="rst-mono">REQ-4471</dd> |
| + <dt>Reported by</dt> |
| + <dd>fiona@example.com</dd> |
| + <dt>Queue</dt> |
| + <dd>Billing</dd> |
| + <dt>Opened</dt> |
| + <dd><time datetime="2026-08-14T09:12">14 August, 09:12</time></dd> |
| +</dl></section> |
| +<div rst-box-head><h2>Reply</h2></div> |
| +<section rst-box><form rst-form method="post" action="#"><div rst-field> |
| + <label rst-field-label for="reply">Your reply</label> |
| + <textarea rst-textarea id="reply" name="reply" rows="4" aria-describedby="reply-hint"></textarea> |
| + <small rst-field-hint id="reply-hint">The person who reported this gets it by email.</small> |
| +</div> |
| +<div rst-form-foot> |
| + <button rst-btn="primary lg" type="submit">Send reply</button> |
| + <a rst-btn="lg" data-gallery-nav href="{{.Requests}}">Cancel</a> |
| +</div></form></section> |
| +<div rst-callout rst-tone="info"><span rst-callout-ic 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"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg></span><div rst-callout-body><strong>Every screen has its own address</strong><p>{{.Callout}}</p></div></div> |
| +<p><a rst-btn data-gallery-nav href="{{.Overview}}">{{.Back}}</a></p> |
| +</section> |
diff --git a/internal/gallery/demo/requests.html b/internal/gallery/demo/requests.html| new file mode 100644 |
| index 0000000..43592ab |
| --- /dev/null |
| +++ b/internal/gallery/demo/requests.html |
| @@ -0,0 +1,59 @@ |
| +{{/* The demo application's list: demo-requests.html. Rastrillo's demo, |
| +written in the markup of the Fiesta examples that document each piece: |
| +page-header, seg-tabs, list-bar, the card rows, status-pill, row-menu |
| +and pagination. */}} |
| +<section class="app-view" id="view-requests"> |
| +<header rst-page-header> |
| + <div rst-page-header-titles> |
| + <h1>Requests</h1> |
| + <p rst-page-header-sub>Every request in the queue, newest first.</p> |
| + </div> |
| + <a rst-btn="primary" data-gallery-nav href="{{.Requests}}"><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="M5 12h14"/><path d="M12 5v14"/></svg>New request</a> |
| +</header> |
| +<nav rst-seg-tabs aria-label="Requests"><a data-gallery-nav href="{{.Requests}}" aria-current="page">All</a><a data-gallery-nav href="{{.Requests}}">Open</a><a data-gallery-nav href="{{.Requests}}">Resolved</a></nav> |
| +<div rst-card style="--rst-cols: minmax(0, 1fr) 120px 120px var(--rst-col-menu)"> |
| +<div rst-lbar> |
| + <search><form rst-search method="get" data-gallery-nav action="{{.Requests}}"> |
| + <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="Search requests" aria-label="Search requests"> |
| + <button class="rst-sr-only" type="submit">Search</button> |
| +</form></search> |
| +</div> |
| +<div rst-lrow="head"><span>Subject</span><span class="rst-m-hide">Status</span><span class="rst-m-hide">Updated</span><span></span></div> |
| +<div rst-lrow><a class="rst-nm" data-gallery-nav href="{{.Request}}">Invoice #4471 never arrived<small><bdi>Fiona Reid</bdi> · Billing</small></a><span class="rst-m-hide"><span rst-status rst-tone="warning">Waiting</span></span><span class="rst-cell-mut rst-m-hide">09:12</span><details rst-row-menu name="rst-menus"> |
| + <summary aria-label="Actions for Invoice #4471 never arrived"><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="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg></summary> |
| + <div rst-row-menu-panel> |
| + <a data-gallery-nav href="{{.Request}}">Reply</a> |
| + <hr> |
| + <a class="rst-danger" data-gallery-nav href="{{.Request}}">Close request…</a> |
| + </div> |
| +</details></div> |
| +<div rst-lrow><a class="rst-nm" data-gallery-nav href="{{.Request}}">Card declined on renewal<small><bdi>Otto Neurath</bdi> · Billing</small></a><span class="rst-m-hide"><span rst-status rst-tone="neutral">Open</span></span><span class="rst-cell-mut rst-m-hide">08:40</span><details rst-row-menu name="rst-menus"> |
| + <summary aria-label="Actions for Card declined on renewal"><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="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg></summary> |
| + <div rst-row-menu-panel> |
| + <a data-gallery-nav href="{{.Request}}">Reply</a> |
| + <hr> |
| + <a class="rst-danger" data-gallery-nav href="{{.Request}}">Close request…</a> |
| + </div> |
| +</details></div> |
| +<div rst-lrow><a class="rst-nm" data-gallery-nav href="{{.Request}}">Export takes twenty minutes<small><bdi>Mary Sherman</bdi> · Data</small></a><span class="rst-m-hide"><span rst-status rst-tone="neutral">Open</span></span><span class="rst-cell-mut rst-m-hide">12 August</span><details rst-row-menu name="rst-menus"> |
| + <summary aria-label="Actions for Export takes twenty minutes"><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="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg></summary> |
| + <div rst-row-menu-panel> |
| + <a data-gallery-nav href="{{.Request}}">Reply</a> |
| + <hr> |
| + <a class="rst-danger" data-gallery-nav href="{{.Request}}">Close request…</a> |
| + </div> |
| +</details></div> |
| +<div rst-lrow><a class="rst-nm" data-gallery-nav href="{{.Request}}">Seat count is wrong on the invoice<small><bdi>Hedy Lamarr</bdi> · Billing</small></a><span class="rst-m-hide"><span rst-status rst-tone="positive">Resolved</span></span><span class="rst-cell-mut rst-m-hide">11 August</span><details rst-row-menu name="rst-menus"> |
| + <summary aria-label="Actions for Seat count is wrong on the invoice"><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="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg></summary> |
| + <div rst-row-menu-panel> |
| + <a data-gallery-nav href="{{.Request}}">Reply</a> |
| + <hr> |
| + <a class="rst-danger" data-gallery-nav href="{{.Request}}">Close request…</a> |
| + </div> |
| +</details></div> |
| +</div> |
| +<p rst-count-line>1–4 of 24 requests</p> |
| +<nav rst-pagination aria-label="Pagination"><span aria-current="page">1</span><a data-gallery-nav href="{{.Requests}}">2</a><a data-gallery-nav href="{{.Requests}}">3</a></nav> |
| +<p><a rst-btn data-gallery-nav href="{{.Overview}}">{{.Back}}</a></p> |
| +</section> |
diff --git a/internal/gallery/demo_browser_test.go b/internal/gallery/demo_browser_test.go| new file mode 100644 |
| index 0000000..45e20de |
| --- /dev/null |
| +++ b/internal/gallery/demo_browser_test.go |
| @@ -0,0 +1,239 @@ |
| +//go:build browser |
| + |
| +package gallery |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "net/http" |
| + "sort" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/chromedp" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/axe" |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// The demo application in an engine: its journeys with scripts off, |
| +// its rows clickable edge to edge, and its four documents under axe. |
| + |
| +// demoURL is one of the demo's four documents in day/en, by view. |
| +func demoURL(rig *harness.Rig, view string) string { |
| + return rig.Origin + href(fiesta.ThemeNames()[0]+"/en/"+demoFile(view)) |
| +} |
| + |
| +// TestTheDemoApplicationWorksWithNoScript is the demo's claim: four |
| +// pages, each a real document, and with script execution disabled in |
| +// the engine every journey still works. At 1280 the rail and the pages: |
| +// land, follow the rail to the list, a row into the request, the back |
| +// link out. At 390 the phone index: the rail is the page, a row opens |
| +// the list with a back control and no rail, and the back control |
| +// returns to the index with the row it left as the :target. |
| +// |
| +// At every stop exactly one view is on the page, read off computed |
| +// style: a second visible view would be two page headers stacked, |
| +// which reads as a broken build rather than as an app. Ported; the |
| +// control that scripts are off is the gallery's own marker, read on a |
| +// gallery page in the same tab, since the demo loads no gallery.js. |
| +func TestTheDemoApplicationWorksWithNoScript(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| + defer cancel() |
| + ctx = noScripts(t, ctx) |
| + const views = `[...document.querySelectorAll(".app-view")].filter(v => getComputedStyle(v).display !== "none").map(v => v.id).join(",")` |
| + shown := func(sel string) bool { |
| + t.Helper() |
| + var b bool |
| + mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`(() => { const e = document.querySelector(%q); return !!e && e.getClientRects().length > 0; })()`, sel), &b)) |
| + return b |
| + } |
| + // goTo clicks sel and waits until the address ends with file and |
| + // the destination's own section is in the document: the rail is in |
| + // all four documents, so waiting for a rail link would pass in the |
| + // page being left. It also waits out the narrow slide between |
| + // pages, since a click sent during a view transition lands on its |
| + // snapshot and does nothing. |
| + goTo := func(sel, file, marker string) { |
| + t.Helper() |
| + mustRun(t, ctx, chromedp.Click(sel, chromedp.ByQuery)) |
| + until(t, ctx, "clicking "+sel, fmt.Sprintf(`location.pathname.endsWith(%q) && !!document.querySelector(%q) && document.readyState === "complete" && !document.activeViewTransition`, "/"+file, marker)) |
| + } |
| + mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+href(fiesta.ThemeNames()[0]+"/en/index.html")), chromedp.WaitReady("body")) |
| + requireScriptsOff(t, ctx, "the Overview in the same tab") |
| + |
| + var landed, list, detail, back string |
| + mustRun(t, ctx, chromedp.Navigate(demoURL(rig, "index")), chromedp.WaitReady("body")) |
| + mustRun(t, ctx, chromedp.Evaluate(views, &landed)) |
| + if landed != "view-dashboard" { |
| + t.Fatalf("landing on demo.html the page shows %q, want exactly view-dashboard; nothing to drive", landed) |
| + } |
| + goTo(`#nav-requests`, "demo-requests.html", "#view-requests") |
| + mustRun(t, ctx, chromedp.Evaluate(views, &list)) |
| + goTo(`#view-requests [rst-lrow] a.rst-nm`, "demo-request.html", "#view-request") |
| + mustRun(t, ctx, chromedp.Evaluate(views, &detail)) |
| + goTo(`#view-request [rst-back-nav] a`, "demo-requests.html", "#view-requests") |
| + mustRun(t, ctx, chromedp.Evaluate(views, &back)) |
| + for _, s := range []struct{ where, got, want string }{ |
| + {"landing on demo.html", landed, "view-dashboard"}, {"the rail to the list", list, "view-requests"}, |
| + {"a row into the request", detail, "view-request"}, {"the back link out", back, "view-requests"}, |
| + } { |
| + if s.got != s.want { |
| + t.Errorf("%s: the page shows %q, want exactly %q", s.where, s.got, s.want) |
| + } |
| + } |
| + |
| + mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(demoURL(rig, "index")), chromedp.WaitReady("body")) |
| + if !shown("#nav-requests") || shown("[rst-shell-main]") { |
| + t.Errorf("demo.html at 390 is not the index (rail shown %v, main shown %v)", shown("#nav-requests"), shown("[rst-shell-main]")) |
| + } |
| + goTo("#nav-requests", "demo-requests.html", "#view-requests") |
| + if !shown("[rst-shell-back] a") || shown("#nav-requests") { |
| + t.Errorf("the list at 390: back control shown %v, rail shown %v; want the back control and no rail", shown("[rst-shell-back] a"), shown("#nav-requests")) |
| + } |
| + goTo("[rst-shell-back] a", "demo.html", "#view-dashboard") |
| + var target bool |
| + mustRun(t, ctx, chromedp.Evaluate(`document.getElementById("nav-requests").matches(":target")`, &target)) |
| + if !target { |
| + t.Error("back on the index, the Requests row is not the :target; the scriptless focus return has nothing to start from") |
| + } |
| +} |
| + |
| +// TestTheDemoRequestsRowsAreClickableEdgeToEdge is the whole-row |
| +// target's demo leg. The Requests list is a list grid with a status |
| +// pill in every row, written with no markup of its own for the |
| +// whole-row rule, so it is the check that the rule reaches a real |
| +// screen from tokens.css alone. At 1280 with a mouse and 390 with a |
| +// coarse pointer, a click at the row's far edge opens the request; at |
| +// 1280, where the status and updated columns are shown, so does a click |
| +// on the pill and on the time. The destination is read off the request |
| +// page's own section being in the document, which only the click can |
| +// produce. Ported. |
| +func TestTheDemoRequestsRowsAreClickableEdgeToEdge(t *testing.T) { |
| + for _, leg := range []struct { |
| + name string |
| + w, h int64 |
| + coarse bool |
| + }{{"1280 mouse", 1280, 900, false}, {"390 touch", 390, 844, true}} { |
| + t.Run(leg.name, func(t *testing.T) { |
| + var opts []harness.Option |
| + if leg.coarse { |
| + opts = append(opts, harness.WithCoarsePointer()) |
| + } |
| + rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }, opts...) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| + defer cancel() |
| + url := demoURL(rig, "requests") |
| + mustRun(t, ctx, chromedp.EmulateViewport(leg.w, leg.h), chromedp.Navigate(url), chromedp.WaitReady("body")) |
| + var rows int |
| + mustRun(t, ctx, chromedp.Evaluate(`document.querySelectorAll('#view-requests [rst-lrow]:not([rst-lrow~="head"])').length`, &rows)) |
| + if rows == 0 { |
| + t.Fatalf("%s has no request rows to click", url) |
| + } |
| + var coarse bool |
| + mustRun(t, ctx, chromedp.Evaluate(`matchMedia("(pointer: coarse)").matches`, &coarse)) |
| + if coarse != leg.coarse { |
| + t.Fatalf("(pointer: coarse) is %v, this leg needs %v; the rig is not the device this leg claims to measure", coarse, leg.coarse) |
| + } |
| + const row = `#view-requests [rst-lrow]:not([rst-lrow~="head"])` |
| + for _, c := range []struct { |
| + where, sel string |
| + fx, dx float64 |
| + wideOnly bool |
| + }{ |
| + {"the first row's far edge", row, 1, -12, false}, |
| + // Both cells are rst-m-hide: below 800px they have no box. |
| + {"the first row's status pill", row + ` [rst-status]`, 0.5, 0, true}, |
| + {"the first row's updated time", row + ` .rst-cell-mut`, 0.5, 0, true}, |
| + } { |
| + if c.wideOnly && leg.coarse { |
| + continue |
| + } |
| + mustRun(t, ctx, chromedp.Navigate(url)) |
| + until(t, ctx, leg.name, `!!document.getElementById("view-requests") && document.readyState === "complete" && !document.activeViewTransition`) |
| + var p struct { |
| + X, Y float64 |
| + Hit string |
| + } |
| + at(t, ctx, fmt.Sprintf(`(() => { const el = document.querySelector(%q); el.scrollIntoView({block: "center"}); const r = el.getBoundingClientRect(); |
| + const x = r.left + r.width * %v + %v, y = r.top + r.height / 2, h = document.elementFromPoint(x, y), link = document.querySelector(%q); |
| + return JSON.stringify({X: x, Y: y, Hit: h === link ? "the row's link" : h ? h.tagName + " " + h.className : "nothing"}); })()`, c.sel, c.fx, c.dx, row+` > a.rst-nm`), &p) |
| + if p.Hit != "the row's link" { |
| + t.Errorf("%s: at %s the element under the pointer is %s, want the row's link", leg.name, c.where, p.Hit) |
| + } |
| + mustRun(t, ctx, chromedp.MouseClickXY(p.X, p.Y)) |
| + if !demoPoll(ctx, `location.pathname.endsWith("/demo-request.html") && !!document.getElementById("view-request")`) { |
| + t.Errorf("%s: a click at %s (%.0f, %.0f) did not open the request", leg.name, c.where, p.X, p.Y) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// demoPoll reports whether a page expression became true within 10s. |
| +func demoPoll(ctx context.Context, expr string) bool { |
| + deadline := time.Now().Add(10 * time.Second) |
| + for time.Now().Before(deadline) { |
| + var yes bool |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(expr, &yes)); err == nil && yes { |
| + return true |
| + } |
| + time.Sleep(100 * time.Millisecond) |
| + } |
| + return false |
| +} |
| + |
| +// TestTheDemoApplicationPassesAxe: the demo's four documents in day, |
| +// with data-theme light and dark, at 1280, under the WCAG 2.2 AA tag |
| +// set. Expected: no violations; the rules axe could not decide are |
| +// logged. The body's ground is read back after each flip and the two |
| +// must differ, so a dark scan of a page still painted light cannot pass |
| +// as two scans. |
| +func TestTheDemoApplicationPassesAxe(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 5*time.Minute) |
| + defer cancel() |
| + theme := fiesta.ThemeNames()[0] |
| + incomplete := map[string]int{} |
| + scans, failed := 0, 0 |
| + for _, view := range demoViews { |
| + file := demoFile(view) |
| + where := theme + "/en/" + file |
| + mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+href(where)), chromedp.WaitReady("body")) |
| + settleMotion(t, ctx, where) |
| + var views int |
| + mustRun(t, ctx, chromedp.Evaluate(`document.querySelectorAll(".app-view").length`, &views)) |
| + if views != 1 { |
| + t.Fatalf("%s shows %d of the demo's views, want one; the scan would read something else", where, views) |
| + } |
| + ground := map[string]string{} |
| + for _, scheme := range []string{"light", "dark"} { |
| + var bg string |
| + mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`document.documentElement.setAttribute("data-theme", %q); getComputedStyle(document.body).backgroundColor`, scheme), &bg)) |
| + ground[scheme] = bg |
| + res, err := axe.Scan(ctx, "", axe.DefaultTags()) |
| + if err != nil { |
| + t.Fatalf("%s %s: %v", where, scheme, err) |
| + } |
| + scans++ |
| + for _, id := range res.Incomplete { |
| + incomplete[id]++ |
| + } |
| + failed += report(t, where+" "+scheme, res.Violations) |
| + } |
| + if ground["light"] == ground["dark"] { |
| + t.Fatalf("%s: the body paints %s under both schemes, so the flip to dark did not take", where, ground["light"]) |
| + } |
| + } |
| + if scans != 2*len(demoViews) { |
| + t.Fatalf("%d scans, want %d", scans, 2*len(demoViews)) |
| + } |
| + var ids []string |
| + for id, n := range incomplete { |
| + ids = append(ids, fmt.Sprintf("%s x%d", id, n)) |
| + } |
| + sort.Strings(ids) |
| + t.Logf("%d scans, %d violations; needs review, axe could not decide: %v", scans, failed, ids) |
| +} |
diff --git a/internal/gallery/demo_test.go b/internal/gallery/demo_test.go| new file mode 100644 |
| index 0000000..d60de8b |
| --- /dev/null |
| +++ b/internal/gallery/demo_test.go |
| @@ -0,0 +1,387 @@ |
| +package gallery |
| + |
| +import ( |
| + "html/template" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| +) |
| + |
| +// The demo application and the modal route as the render writes them: |
| +// four documents in the sidebar shell Fiesta ships, the modal at an |
| +// address of its own, the Overview framing the demo, and nothing else |
| +// in the tree laying a modal over the page it is on. The drives in |
| +// demo_browser_test.go take over where the engine has to answer. |
| + |
| +// demoFiles is the demo's four documents, the index first, keyed by |
| +// the view each shows. |
| +var demoFiles = map[string]string{ |
| + "index": "demo.html", |
| + "dashboard": "demo-dashboard.html", |
| + "requests": "demo-requests.html", |
| + "request": "demo-request.html", |
| +} |
| + |
| +// demoScripts are the scripts a demo document loads, in order: frame.js |
| +// first, as every document the gallery frames, then the four the spec |
| +// names, which are the layout's own minus the ones the demo has no |
| +// control for. |
| +var demoScripts = []string{"frame.js", "js/fiesta.js", "js/light.js", "js/anchor.js", "js/shell.js"} |
| + |
| +// TestTheDemoIsFourDocumentsInTheShippedShell: in every theme and |
| +// locale the demo application is four documents, each the sidebar |
| +// shell the layout examples ship (the index on demo.html, a content |
| +// page on the other three), with the brand Harbour going to the |
| +// demo's own index, a rail of two links with the current one marked |
| +// for the view, the way up to the index at the row just left, Ada |
| +// Lovelace at the foot, the language menu going to the same document |
| +// in each of the twelve locales with this one marked, the view's own |
| +// section and no other, a way back to the design system, the scripts |
| +// the spec names and no others, and no link out of the gallery's own: |
| +// every href in the body is the gallery's or a fragment. |
| +func TestTheDemoIsFourDocumentsInTheShippedShell(t *testing.T) { |
| + const mount = "/x" |
| + files := render(t, mount) |
| + s := newTestSite(t, fiesta.FS(), mount) |
| + attr := func(n node, name string) string { v, _ := n.attr(name); return v } |
| + index := parse(t, files["day/en/frames/layout-sidebar/default.html"]) |
| + page := parse(t, files["day/en/frames/layout-sidebar-page/default.html"]) |
| + read := 0 |
| + for _, dir := range dirs() { |
| + theme, locale, _ := strings.Cut(strings.TrimSuffix(dir, "/"), "/") |
| + for view, file := range demoFiles { |
| + name := dir + file |
| + body, ok := files[name] |
| + if !ok { |
| + t.Errorf("the render wrote no %s", name) |
| + continue |
| + } |
| + read++ |
| + doc := parse(t, body) |
| + |
| + // The shell, and its skeleton the layout's own. |
| + wantShell, layout := "page", page |
| + if view == "index" { |
| + wantShell, layout = "index", index |
| + } |
| + if m := shellRoot.FindSubmatch(body); m == nil || string(m[1]) != wantShell { |
| + t.Errorf("%s does not open the sidebar shell as %q", name, wantShell) |
| + } |
| + if got, want := shellSkeleton(doc), shellSkeleton(layout); !sameKeys(got, want) { |
| + t.Errorf("%s: the shell's skeleton %v is not the layout's %v", name, keysOf(got), keysOf(want)) |
| + } |
| + |
| + // The brand, both copies. |
| + brands := doc.find(byAttr("rst-shell-brand", "")) |
| + if len(brands) == 0 { |
| + t.Errorf("%s has no brand", name) |
| + } |
| + for _, b := range brands { |
| + if b.text() != "Harbour" || attr(b, "href") != s.href(theme, locale, "demo.html") || !hasAttr(b, "data-gallery-nav") { |
| + t.Errorf("%s: the brand reads %q and goes to %q, want Harbour to the demo's index", name, b.text(), attr(b, "href")) |
| + } |
| + } |
| + |
| + // The rail: two links, the current one the view's. |
| + navs := doc.find(func(n node) bool { |
| + return n.n.Data == "a" && within(n, func(p node) bool { return hasAttr(p, "rst-shell-nav") }) |
| + }) |
| + current := "nav-requests" |
| + if view == "index" || view == "dashboard" { |
| + current = "nav-dashboard" |
| + } |
| + if len(navs) != 2 { |
| + t.Errorf("%s: the rail has %d links, want Dashboard and Requests", name, len(navs)) |
| + } else { |
| + for i, want := range []struct{ id, file string }{{"nav-dashboard", "demo-dashboard.html"}, {"nav-requests", "demo-requests.html"}} { |
| + a := navs[i] |
| + if attr(a, "id") != want.id || attr(a, "href") != s.href(theme, locale, want.file) || !hasAttr(a, "data-gallery-nav") { |
| + t.Errorf("%s: rail link %d is id=%q href=%q, want id=%q href=%q", name, i, attr(a, "id"), attr(a, "href"), want.id, s.href(theme, locale, want.file)) |
| + } |
| + if mark := attr(a, "aria-current") == "page"; mark != (want.id == current) { |
| + t.Errorf("%s: %s marked current %v, want %v", name, want.id, mark, want.id == current) |
| + } |
| + } |
| + } |
| + |
| + // The way up: the index itself on the index, and the row |
| + // just left on a content page. |
| + up := doc.find(func(n node) bool { |
| + return n.n.Data == "a" && attr(n, "rel") == "up" && within(n, func(p node) bool { return hasAttr(p, "rst-shell-back") }) |
| + }) |
| + wantUp := s.href(theme, locale, "demo.html") |
| + if view != "index" { |
| + wantUp += "#" + current |
| + } |
| + if len(up) != 1 || attr(up[0], "href") != wantUp || !hasAttr(up[0], "data-gallery-nav") { |
| + t.Errorf("%s: %d ways up, want one to %s", name, len(up), wantUp) |
| + } |
| + |
| + // The person at the foot. |
| + for _, p := range doc.find(byAttr("rst-person-name", "")) { |
| + if p.text() != "Ada Lovelace" { |
| + t.Errorf("%s: the profile names %q, want Ada Lovelace", name, p.text()) |
| + } |
| + } |
| + if strings.Contains(string(body), "Grace Hopper") || strings.Contains(string(body), "Party planner") { |
| + t.Errorf("%s still carries the layout's own person or brand", name) |
| + } |
| + |
| + // The language menu: the same document in every locale. |
| + langs := doc.find(func(n node) bool { |
| + return n.n.Data == "a" && within(n, func(p node) bool { return hasAttr(p, "rst-locale") }) |
| + }) |
| + if len(langs) != len(Locales) { |
| + t.Errorf("%s: %d language links, want %d", name, len(langs), len(Locales)) |
| + } |
| + for _, a := range langs { |
| + lang := attr(a, "lang") |
| + if attr(a, "href") != s.href(theme, lang, file) || !hasAttr(a, "data-gallery-nav") { |
| + t.Errorf("%s: the %s link goes to %q, want %q", name, lang, attr(a, "href"), s.href(theme, lang, file)) |
| + } |
| + if mark := attr(a, "aria-current") == "true"; mark != (lang == locale) { |
| + t.Errorf("%s: the %s link marked current %v", name, lang, mark) |
| + } |
| + } |
| + |
| + // The view, alone. |
| + wantView := "view-" + view |
| + if view == "index" { |
| + wantView = "view-dashboard" |
| + } |
| + views := doc.find(byClass("app-view")) |
| + if len(views) != 1 || attr(views[0], "id") != wantView { |
| + var ids []string |
| + for _, v := range views { |
| + ids = append(ids, attr(v, "id")) |
| + } |
| + t.Errorf("%s shows the views %v, want exactly %s", name, ids, wantView) |
| + } |
| + |
| + // The way back to the design system. |
| + back := s.say(locale, "gallery.back") |
| + found := false |
| + for _, a := range bodyLinks(t, doc) { |
| + if a.text() == back && attr(a, "href") == s.href(theme, locale, "index.html") && hasAttr(a, "data-gallery-nav") { |
| + found = true |
| + } |
| + } |
| + if !found { |
| + t.Errorf("%s has no %q link to the Overview", name, back) |
| + } |
| + |
| + // The scripts. |
| + var scripts []string |
| + for _, sc := range doc.find(func(n node) bool { return n.n.Data == "script" }) { |
| + scripts = append(scripts, attr(sc, "src")) |
| + } |
| + var want []string |
| + for _, sc := range demoScripts { |
| + want = append(want, s.asset(sc)) |
| + } |
| + if strings.Join(scripts, " ") != strings.Join(want, " ") { |
| + t.Errorf("%s loads %v, want %v", name, scripts, want) |
| + } |
| + |
| + // Nothing leaves the gallery. |
| + for _, a := range bodyLinks(t, doc) { |
| + h := attr(a, "href") |
| + if !strings.HasPrefix(h, "#") && !(hasAttr(a, "data-gallery-nav") && strings.HasPrefix(h, mount+"/")) { |
| + t.Errorf("%s: %q goes to %q, neither the gallery's nor a fragment", name, a.text(), h) |
| + } |
| + } |
| + } |
| + } |
| + if read != len(dirs())*len(demoFiles) { |
| + t.Fatalf("read %d demo documents, want %d", read, len(dirs())*len(demoFiles)) |
| + } |
| +} |
| + |
| +// sameKeys says whether two sets hold the same keys. |
| +func sameKeys(a, b map[string]bool) bool { |
| + if len(a) != len(b) { |
| + return false |
| + } |
| + for k := range a { |
| + if !b[k] { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +// keysOf is a set's keys, for a message. |
| +func keysOf(m map[string]bool) []string { |
| + var out []string |
| + for k := range m { |
| + out = append(out, k) |
| + } |
| + return out |
| +} |
| + |
| +// liveOverlay is a modal laid out live: the overlay or the backdrop as |
| +// an element, never as escaped source in a code block. |
| +var liveOverlay = regexp.MustCompile(`<div rst-(modal-overlay|backdrop)\b`) |
| + |
| +// TestNoGalleryPageOpensAModalOverTheGallery: no document under a theme |
| +// and locale lays a modal over itself but the modal route's own, |
| +// demo-modal.html (the frames are documents of their own, framed, and |
| +// excluded); the modal example's page shows the overlay as source and |
| +// links the route in a new tab; and on the route itself the panel's |
| +// tabs link the route, Close and the back button go to the Overview, |
| +// and the backdrop holds a page that is inert. |
| +func TestNoGalleryPageOpensAModalOverTheGallery(t *testing.T) { |
| + const mount = "/x" |
| + files := render(t, mount) |
| + s := newTestSite(t, fiesta.FS(), mount) |
| + attr := func(n node, name string) string { v, _ := n.attr(name); return v } |
| + routes := 0 |
| + for name, body := range files { |
| + if !strings.HasSuffix(name, ".html") || isFrame(name) || strings.HasSuffix(name, "/demo-modal.html") { |
| + continue |
| + } |
| + if m := liveOverlay.Find(body); m != nil { |
| + t.Errorf("%s lays out a live %s over the page", name, m) |
| + } |
| + } |
| + for _, dir := range dirs() { |
| + theme, locale, _ := strings.Cut(strings.TrimSuffix(dir, "/"), "/") |
| + self := s.href(theme, locale, "demo-modal.html") |
| + overview := s.href(theme, locale, "index.html") |
| + |
| + page := examplePage(t, files, theme, locale, "modal") |
| + if !strings.Contains(strings.Join(sourceTexts(page), "\n"), `<div rst-modal-overlay>`) { |
| + t.Errorf("%smodal.html does not show the overlay as source", dir) |
| + } |
| + link := regexp.MustCompile(`<a data-gallery-nav href="` + regexp.QuoteMeta(self) + `" target="_blank" rel="noopener">`) |
| + if !link.MatchString(page) { |
| + t.Errorf("%smodal.html does not link %s in a new tab", dir, self) |
| + } |
| + |
| + name := dir + "demo-modal.html" |
| + body, ok := files[name] |
| + if !ok { |
| + t.Errorf("the render wrote no %s", name) |
| + continue |
| + } |
| + routes++ |
| + doc := parse(t, body) |
| + if n := len(liveOverlay.FindAll(body, -1)); n != 2 { |
| + t.Errorf("%s lays out %d of the backdrop and the overlay, want both", name, n) |
| + } |
| + backdrop, ok := doc.first(byAttr("rst-backdrop", "")) |
| + if !ok || !hasAttr(backdrop, "inert") { |
| + t.Errorf("%s: the backdrop is missing or not inert", name) |
| + } else { |
| + if h, ok := backdrop.first(func(n node) bool { return n.n.Data == "h1" }); !ok || h.text() != s.say(locale, "gallery.modal.settings") { |
| + t.Errorf("%s: the page under the modal has no heading %q", name, s.say(locale, "gallery.modal.settings")) |
| + } |
| + if !strings.Contains(backdrop.text(), s.say(locale, "gallery.modal.own")) { |
| + t.Errorf("%s: the page under the modal does not say %q", name, s.say(locale, "gallery.modal.own")) |
| + } |
| + } |
| + panel, ok := doc.first(byAttr("rst-modal-panel", "")) |
| + if !ok { |
| + t.Errorf("%s has no panel", name) |
| + continue |
| + } |
| + tabs := panel.find(func(n node) bool { |
| + return n.n.Data == "a" && within(n, func(p node) bool { return p.n.Data == "nav" }) |
| + }) |
| + if len(tabs) != 3 { |
| + t.Errorf("%s: the panel has %d tabs, want 3", name, len(tabs)) |
| + } |
| + for i, a := range tabs { |
| + if attr(a, "href") != self || !hasAttr(a, "data-gallery-nav") { |
| + t.Errorf("%s: tab %q goes to %q, want the route itself", name, a.text(), attr(a, "href")) |
| + } |
| + if mark := attr(a, "aria-current") == "page"; mark != (i == 0) { |
| + t.Errorf("%s: tab %q marked current %v", name, a.text(), mark) |
| + } |
| + } |
| + closers := panel.find(byAttr("rst-modal-close", "")) |
| + if len(closers) != 1 || attr(closers[0], "href") != overview || attr(closers[0], "aria-label") != s.say(locale, "gallery.modal.close") { |
| + t.Errorf("%s: Close does not go to the Overview under its name", name) |
| + } |
| + back := false |
| + for _, a := range panel.find(func(n node) bool { return n.n.Data == "a" }) { |
| + if a.text() == s.say(locale, "gallery.back") && attr(a, "href") == overview && hasAttr(a, "data-gallery-nav") { |
| + back = true |
| + } |
| + } |
| + if !back { |
| + t.Errorf("%s: the panel has no %q button to the Overview", name, s.say(locale, "gallery.back")) |
| + } |
| + for _, id := range []string{"gallery.modal.body", "gallery.modal.nojs", "gallery.modal.inapp"} { |
| + if !strings.Contains(panel.text(), squash(s.say(locale, id))) { |
| + t.Errorf("%s: the panel does not say %q", name, s.say(locale, id)) |
| + } |
| + } |
| + } |
| + if routes != len(dirs()) { |
| + t.Fatalf("read %d modal routes, want %d", routes, len(dirs())) |
| + } |
| +} |
| + |
| +// TestTheOverviewFramesTheDemo: every Overview frames the demo |
| +// application between its intro and its routes: the heading and its |
| +// lead, one widget at the page width with Desktop and Mobile and no |
| +// HTML tab (an application is not a snippet to paste), at the demo's |
| +// heights, framing its own directory's demo.html, and the link that |
| +// opens it in a new tab and says so. |
| +func TestTheOverviewFramesTheDemo(t *testing.T) { |
| + const mount = "/x" |
| + files := render(t, mount) |
| + s := newTestSite(t, fiesta.FS(), mount) |
| + for _, dir := range dirs() { |
| + theme, locale, _ := strings.Cut(strings.TrimSuffix(dir, "/"), "/") |
| + name := dir + "index.html" |
| + page := string(files[name]) |
| + section := regexp.MustCompile(`(?s)<section class="ds-demo" id="demo-app">(.*?)</section>`).FindStringSubmatch(page) |
| + if section == nil { |
| + t.Errorf("%s frames no demo", name) |
| + continue |
| + } |
| + intro, demo, next := strings.Index(page, `class="ds-intro"`), strings.Index(page, `class="ds-demo"`), strings.Index(page, `class="ds-routes"`) |
| + if !(intro >= 0 && intro < demo && demo < next) { |
| + t.Errorf("%s: the demo is not between the intro and the routes", name) |
| + } |
| + body := section[1] |
| + for _, id := range []string{"gallery.demo.heading", "gallery.demo.lead"} { |
| + if !strings.Contains(body, template.HTMLEscapeString(s.say(locale, id))) { |
| + t.Errorf("%s: the demo section does not say %q", name, s.say(locale, id)) |
| + } |
| + } |
| + widgets := widgetsOf(body) |
| + if len(widgets) != 1 { |
| + t.Errorf("%s: %d widgets in the demo section, want 1", name, len(widgets)) |
| + continue |
| + } |
| + w := widgets[0] |
| + if _, ok := previewHeights["demo"]; !ok { |
| + t.Fatal("heights.go has no measured height for the demo") |
| + } |
| + if !strings.Contains(body, `<div class="ds-view ds-view--page" style="`+string(previewStyle(locale, "demo"))) { |
| + t.Errorf("%s: the demo's widget is not at the page width and the demo's heights (%s)", name, previewStyle(locale, "demo")) |
| + } |
| + var order string |
| + for _, m := range tabClass.FindAllStringSubmatch(w, -1) { |
| + order += m[1] |
| + } |
| + if order != "dm" || panelOf(w) != "" { |
| + t.Errorf("%s: the demo's tabs are %q with panel %v, want Desktop and Mobile and no HTML", name, order, panelOf(w) != "") |
| + } |
| + src := `src="` + s.href(theme, locale, "demo.html") + `"` |
| + if strings.Count(w, `<iframe class="ds-view__frame"`) != 1 || !strings.Contains(w, src) { |
| + t.Errorf("%s: the widget does not frame %s", name, src) |
| + } |
| + open := `<a data-gallery-nav href="` + s.href(theme, locale, "demo.html") + `" target="_blank" rel="noopener">` + |
| + template.HTMLEscapeString(s.say(locale, "gallery.demo.open")) + |
| + `<span class="rst-sr-only"> (` + template.HTMLEscapeString(s.say(locale, "gallery.newtab")) + `)</span></a>` |
| + if !strings.Contains(body, open) { |
| + t.Errorf("%s: no link opens the demo in a new tab saying so", name) |
| + } |
| + } |
| +} |
diff --git a/internal/gallery/example_page.go b/internal/gallery/example_page.go| index 8fee8a1..b5f0aa8 100644 |
| --- a/internal/gallery/example_page.go |
| +++ b/internal/gallery/example_page.go |
| @@ -52,8 +52,10 @@ type groupNote struct { |
| // state as a frame of its own document with the note on the state |
| // under its label, the warning where the prose has one, the widget, |
| // and the routes it stands for; or, for a grouped example, one widget |
| -// and a block per state under it. A layout's page ends with the button |
| -// that opens the shell's document in a tab of its own. |
| +// and a block per state under it. The three primitives that are also |
| +// pages of their own end with the link to that page, and a layout's |
| +// page with the button that opens the shell's document, each in a tab |
| +// of its own. |
| const exampleBody = `{{define "ds-body-example"}}<p class="ds-kicker"><b>{{.Family.Title}}</b> {{.Family.Blurb}}</p> |
| {{if .Blurb}}<p rst-page-header-sub>{{.Blurb}}</p> |
| {{end}}<p class="ds-lead">{{say "gallery.example.live"}} {{say "gallery.example.html"}}</p> |
| @@ -70,9 +72,42 @@ const exampleBody = `{{define "ds-body-example"}}<p class="ds-kicker"><b>{{.Fami |
| {{end}}{{template "ds-view" .Preview}} |
| {{if .Routes}}<p class="ds-routes-note">{{.Routes}}</p> |
| {{end}}</section> |
| -{{end}}{{end}}{{if .Shell}}<p><a rst-btn data-gallery-nav href="{{.ShellHref}}" target="_blank" rel="noopener">{{say "gallery.shell.open" "shell" .Shell}}<span class="rst-sr-only"> ({{say "gallery.newtab"}})</span></a></p> |
| +{{end}}{{end}}{{with .Live}}<p class="ds-note"><a data-gallery-nav href="{{.Href}}" target="_blank" rel="noopener">{{.Label}}<span class="rst-sr-only"> ({{say "gallery.newtab"}})</span></a></p> |
| +{{end}}{{if .Shell}}<p><a rst-btn data-gallery-nav href="{{.ShellHref}}" target="_blank" rel="noopener">{{say "gallery.shell.open" "shell" .Shell}}<span class="rst-sr-only"> ({{say "gallery.newtab"}})</span></a></p> |
| {{end}}{{end}}` |
| +// demoIdiom is one primitive that is also a page of the tree: the |
| +// words its link wears, by copy id, and the address it goes to. |
| +type demoIdiom struct { |
| + Label string |
| + Href func(s *site, theme, locale string) string |
| +} |
| + |
| +// demoIdioms are the three primitives whose preview is not the last |
| +// word on them. The two shells are whole page frames, better seen at |
| +// the size of a window than of a paragraph, so each links its layout's |
| +// document; the modal's whole claim is that it is a URL, so the |
| +// demonstration is not complete until you have been to it. Rastrillo's |
| +// three, under Rastrillo's name. |
| +var demoIdioms = map[string]demoIdiom{ |
| + "shell-topbar": {"gallery.primitives.topbar.live", func(s *site, theme, locale string) string { return s.layoutHref(theme, locale, "layout-topbar") }}, |
| + "shell-sidebar": {"gallery.primitives.sidebar.live", func(s *site, theme, locale string) string { return s.layoutHref(theme, locale, "layout-sidebar") }}, |
| + "modal": {"gallery.primitives.modal.live", (*site).modalHref}, |
| +} |
| + |
| +// layoutHref is a layout example's document opened whole: its default |
| +// state's frame document. |
| +func (s *site) layoutHref(theme, locale, name string) string { |
| + state, ok := s.m.Default(name) |
| + if !ok { |
| + // The layouts are frozen in examples/ and named in code; a |
| + // manifest without one is a programming error, as a kind |
| + // fileOf does not know is. |
| + panic("gallery: the manifest has no " + name + " example") |
| + } |
| + return s.href(theme, locale, "frames/"+name+"/"+state+".html") |
| +} |
| + |
| // shellOf is the shell a layout example documents, by the name its |
| // open button says: column, topbar, sidebar, console or stage. The |
| // -page members of the sidebar and console pairs are the same shell's |
| @@ -99,6 +134,7 @@ func (s *site) example(p page, e example.Entry) (body []byte, frames map[string] |
| Notes []groupNote |
| Shell string |
| ShellHref string |
| + Live *link |
| }{page: p, Entry: e, Blurb: s.proseIn(p.Locale, "examples."+e.Name+".blurb", ex.Blurb)} |
| f, _ := s.familyByKey(e.Family) |
| data.Family = s.familyIn(p.Locale, f) |
| @@ -107,7 +143,7 @@ func (s *site) example(p page, e example.Entry) (body []byte, frames map[string] |
| if err != nil { |
| return nil, nil, err |
| } |
| - fr, err := s.frame(doc, p.Theme, p.Locale, e) |
| + fr, err := s.frame(doc, p.Theme, p.Locale, e, st.ID) |
| if err != nil { |
| return nil, nil, fmt.Errorf("%s/%s: %w", e.Name, st.ID, err) |
| } |
| @@ -146,13 +182,16 @@ func (s *site) example(p page, e example.Entry) (body []byte, frames map[string] |
| if err != nil { |
| return nil, nil, err |
| } |
| - fr, err := s.frame(rowDoc, p.Theme, p.Locale, e) |
| + fr, err := s.frame(rowDoc, p.Theme, p.Locale, e, "row") |
| if err != nil { |
| return nil, nil, fmt.Errorf("%s/row: %w", e.Name, err) |
| } |
| frames["frames/"+e.Name+"/row.html"] = []byte(fr) |
| data.Group = &view |
| } |
| + if d, ok := demoIdioms[e.Name]; ok { |
| + data.Live = &link{Href: d.Href(s, p.Theme, p.Locale), Label: s.say(p.Locale, d.Label)} |
| + } |
| if data.Shell = shellOf(e); data.Shell != "" { |
| data.ShellHref = s.href(p.Theme, p.Locale, "frames/"+e.Name+"/"+e.Default+".html") |
| } |
diff --git a/internal/gallery/frame.go b/internal/gallery/frame.go| index ec8df1a..a72441c 100644 |
| --- a/internal/gallery/frame.go |
| +++ b/internal/gallery/frame.go |
| @@ -77,7 +77,17 @@ const documentMark = ` data-gallery-document` |
| // The example's title and everything else in it are kept. e is the |
| // example the document belongs to: whether it is a layout decides |
| // whether its body is a page and its links come alive. |
| -func (s *site) frame(doc, theme, locale string, e example.Entry) (string, error) { |
| +func (s *site) frame(doc, theme, locale string, e example.Entry, state string) (string, error) { |
| + return s.framed(doc, theme, locale, e, func(inner string) (string, error) { |
| + return s.layoutLinks(inner, e, state, theme, locale) |
| + }) |
| +} |
| + |
| +// framed is frame with the layout's links left to the caller: links |
| +// rewrites a layout document's body before the deadening pass, and a |
| +// nil links leaves the body as written, for a document that marks its |
| +// own live links data-gallery-nav (the demo application's). |
| +func (s *site) framed(doc, theme, locale string, e example.Entry, links func(string) (string, error)) (string, error) { |
| head := headTag.FindStringIndex(doc) |
| body := bodyTag.FindStringIndex(doc) |
| if head == nil || body == nil || body[0] < head[1] { |
| @@ -141,16 +151,14 @@ func (s *site) frame(doc, theme, locale string, e example.Entry) (string, error) |
| } |
| inner, tail := rest[:end], rest[end:] |
| if e.Document { |
| - var err error |
| - if inner, err = s.layoutLinks(inner, e, theme, locale); err != nil { |
| - return "", err |
| + if links != nil { |
| + var err error |
| + if inner, err = links(inner); err != nil { |
| + return "", err |
| + } |
| } |
| } else if signinFirst.MatchString(inner) { |
| - scene, err := s.stageScene() |
| - if err != nil { |
| - return "", err |
| - } |
| - inner = "\n" + stage(scene, inner) + "\n" |
| + inner = "\n" + stage(s.scene, inner) + "\n" |
| } |
| inner = s.pictures(inner) |
| // Every href and action that is not the gallery's own goes to "#": |
| @@ -181,7 +189,8 @@ func (s *site) pictures(html string) string { |
| // stageScene is the stage's scene, the <div rst-stage-scene> with its |
| // art, lifted from the layout-stage example's default state, so the |
| -// staged sign-in screens show the stage the layout documents. |
| +// staged sign-in screens show the stage the layout documents. newSite |
| +// calls it once and keeps the scene on the site. |
| func (s *site) stageScene() (string, error) { |
| const name = "layout-stage" |
| state, ok := s.m.Default(name) |
| @@ -221,16 +230,16 @@ func stage(scene, body string) string { |
| // and go to "#" afterwards. |
| // |
| // The shell of a layout is its name without layout- and -page: the |
| -// index is layout-<shell>, the page layout-<shell>-page. A layout has |
| -// one state, so the document in another locale is its default state's. |
| -func (s *site) layoutLinks(doc string, e example.Entry, theme, locale string) (string, error) { |
| +// index is layout-<shell>, the page layout-<shell>-page. The document |
| +// in another locale is the same state's, the one being framed. |
| +func (s *site) layoutLinks(doc string, e example.Entry, state, theme, locale string) (string, error) { |
| shell := strings.TrimSuffix(strings.TrimPrefix(e.Name, "layout-"), "-page") |
| index := "layout-" + shell |
| indexState, ok := s.m.Default(index) |
| if !ok { |
| return "", fmt.Errorf("%s: the manifest has no %s, the shell's index document", e.Name, index) |
| } |
| - own := func(l string) string { return s.href(theme, l, "frames/"+e.Name+"/"+e.Default+".html") } |
| + own := func(l string) string { return s.href(theme, l, "frames/"+e.Name+"/"+state+".html") } |
| overview := s.href(theme, locale, s.fileOf("overview")) |
| var err error |
| @@ -255,28 +264,42 @@ func (s *site) layoutLinks(doc string, e example.Entry, theme, locale string) (s |
| }) |
| } |
| doc = shellBrand.ReplaceAllStringFunc(doc, func(a string) string { return liveLink(a, overview) }) |
| + if doc, err = s.localeLinks(doc, locale, own); err != nil { |
| + return "", fmt.Errorf("%s: %w", e.Name, err) |
| + } |
| + doc = footLink.ReplaceAllStringFunc(doc, func(m string) string { |
| + return anchorTag.ReplaceAllString(m, liveLink(anchorTag.FindString(m), overview)) |
| + }) |
| + return doc, nil |
| +} |
| + |
| +// localeLinks points every link of a document's language menu at href |
| +// of the link's own locale, marked the gallery's, with the seeded |
| +// current mark removed and the page's own locale marked instead; lang |
| +// and dir are kept. A link with no lang, or a lang the gallery renders |
| +// no locale for, is an error. The layouts' frame documents and the demo |
| +// application both write their menus through it. |
| +func (s *site) localeLinks(doc, locale string, href func(locale string) string) (string, error) { |
| + var err error |
| doc = localeMenu.ReplaceAllStringFunc(doc, func(m string) string { |
| return anchorTag.ReplaceAllStringFunc(m, func(a string) string { |
| lang := langAttr.FindStringSubmatch(a) |
| if lang == nil { |
| - err = fmt.Errorf("%s: a language link carries no lang", e.Name) |
| + err = errors.New("a language link carries no lang") |
| return a |
| } |
| if !known(lang[1]) { |
| - err = fmt.Errorf("%s: the language menu names %s, which the gallery has no locale for", e.Name, lang[1]) |
| + err = fmt.Errorf("the language menu names %s, which the gallery has no locale for", lang[1]) |
| return a |
| } |
| a = currentOn.ReplaceAllString(a, "") |
| - a = liveLink(a, own(lang[1])) |
| + a = liveLink(a, href(lang[1])) |
| if lang[1] == locale { |
| a = tagEnd.ReplaceAllString(a, ` aria-current="true">`) |
| } |
| return a |
| }) |
| }) |
| - doc = footLink.ReplaceAllStringFunc(doc, func(m string) string { |
| - return anchorTag.ReplaceAllString(m, liveLink(anchorTag.FindString(m), overview)) |
| - }) |
| return doc, err |
| } |
diff --git a/internal/gallery/frame_test.go b/internal/gallery/frame_test.go| index 8971a35..b0c1dbf 100644 |
| --- a/internal/gallery/frame_test.go |
| +++ b/internal/gallery/frame_test.go |
| @@ -1,6 +1,7 @@ |
| package gallery |
| import ( |
| + "io/fs" |
| "regexp" |
| "strings" |
| "testing" |
| @@ -474,3 +475,74 @@ func TestEveryFrameCarriesTheSchemeStore(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// countingFS counts the opens of one file in the source it serves, so |
| +// a test can say how often a render read it. |
| +type countingFS struct { |
| + fs.FS |
| + name string |
| + opens *int |
| +} |
| + |
| +func (c countingFS) Open(name string) (fs.File, error) { |
| + if name == c.name { |
| + *c.opens++ |
| + } |
| + return c.FS.Open(name) |
| +} |
| + |
| +// TestTheStageSceneIsLiftedOncePerRender: the scene the ten staged |
| +// sign-in frames share is read from the layout-stage example once, when |
| +// the site is set up, not once per staged frame: a whole theme and |
| +// locale rendered after the setup reads that document once more, for |
| +// the layout-stage example's own page, and no more. |
| +func TestTheStageSceneIsLiftedOncePerRender(t *testing.T) { |
| + opens := 0 |
| + src := countingFS{FS: fiesta.FS(), name: "examples/layout-stage/" + mustEntry(t, "layout-stage").Default + ".html", opens: &opens} |
| + s := newTestSite(t, src, "") |
| + setup := opens |
| + if err := s.theme("day", "en"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if staged := strings.Count(string(s.out["day/en/frames/signin/asking-for-an-address.html"]), "<div rst-stage-scene"); staged != 1 { |
| + t.Fatalf("the staged sign-in frame carries %d scenes, so the count below measures nothing", staged) |
| + } |
| + if setup != 1 { |
| + t.Errorf("the setup read the layout-stage document %d times, want once", setup) |
| + } |
| + if during := opens - setup; during != 1 { |
| + t.Errorf("rendering day/en read the layout-stage document %d times, want once (its own page); the scene is lifted per frame", during) |
| + } |
| +} |
| + |
| +// TestALayoutsLanguageLinksNameTheStateItFrames: the language menu of a |
| +// layout's frame document goes to the same state's document in each |
| +// locale, the state being the one framed, so a layout with a second |
| +// state would not send a reader of that state to the first. Driven |
| +// through frame() with a state the layout does not have, since every |
| +// layout has one state today. |
| +func TestALayoutsLanguageLinksNameTheStateItFrames(t *testing.T) { |
| + s := newTestSite(t, fiesta.FS(), "") |
| + e := mustEntry(t, "layout-sidebar") |
| + doc, err := example.Document(fiesta.FS(), e.Name, e.Default) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + framed, err := s.frame(doc, "day", "ga", e, "second") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + langs := parse(t, []byte(framed)).find(func(n node) bool { |
| + return n.n.Data == "a" && within(n, func(p node) bool { return hasAttr(p, "rst-locale") }) |
| + }) |
| + if len(langs) != len(Locales) { |
| + t.Fatalf("%d language links, want %d", len(langs), len(Locales)) |
| + } |
| + for _, a := range langs { |
| + lang, _ := a.attr("lang") |
| + href, _ := a.attr("href") |
| + if want := s.href("day", lang, "frames/layout-sidebar/second.html"); href != want { |
| + t.Errorf("the %s link goes to %q, want %q", lang, href, want) |
| + } |
| + } |
| +} |
diff --git a/internal/gallery/gallery.css b/internal/gallery/gallery.css| index d94a503..a928a3e 100644 |
| --- a/internal/gallery/gallery.css |
| +++ b/internal/gallery/gallery.css |
| @@ -22,8 +22,12 @@ |
| sideways for anything. */ |
| [rst-page] h1, [rst-page] h2 { overflow-wrap: anywhere; } |
| /* An example page's family, under its heading: the family's name and |
| - its sentence, as a kicker back to the page that files it. */ |
| -.ds-kicker { color: var(--rst-text-muted); font-size: var(--rst-fs-sm); margin: 0 0 var(--rst-sp-2); max-width: 62ch; } |
| + its sentence, as a kicker back to the page that files it. Then the |
| + example's own sentence and the two every example page says. All |
| + three read as Rastrillo's page head reads its leads: one size, one |
| + colour, one step between them, so the head is a column of sentences |
| + rather than three type sizes stacked. */ |
| +.ds-kicker, [rst-page] > [rst-page-header-sub] { color: var(--rst-text-muted); font-size: inherit; margin: 0 0 var(--rst-sp-4); max-width: 62ch; } |
| .ds-kicker b { color: var(--rst-text); font-weight: 600; } |
| /* One state: a card, its label, the note on it, the widget view.css |
| draws, and the routes it stands for. The label is styled by class, |
diff --git a/internal/gallery/gallery.go b/internal/gallery/gallery.go| index d472df7..0d44a68 100644 |
| --- a/internal/gallery/gallery.go |
| +++ b/internal/gallery/gallery.go |
| @@ -23,6 +23,9 @@ |
| // the example's own with |
| // the gallery's assets |
| // and dead links |
| +// <theme>/<locale>/demo.html, demo-dashboard.html, demo-requests.html, |
| +// demo-request.html the demo application |
| +// <theme>/<locale>/demo-modal.html the modal route |
| // assets/<name>.<hash>.<ext>, assets/<name>.<ext> |
| // every asset hashed and |
| // plain: css/, js/, the |
| @@ -130,6 +133,13 @@ type site struct { |
| hashed map[string]string |
| // tmpl is the parsed templates, one tree per locale. |
| tmpl map[string]*template.Template |
| + // weighed is the release build of the source, each file weighed: |
| + // what Getting started lists and its rail links, built once here |
| + // rather than on each of the thousands of pages whose rail asks. |
| + weighed release |
| + // scene is the stage's scene, lifted once from the layout-stage |
| + // example for every staged sign-in frame. |
| + scene string |
| } |
| // family is one family of examples: its heading and the sentence |
| @@ -176,7 +186,7 @@ func Render(src fs.FS, opts Options) (map[string][]byte, error) { |
| // newSite reads everything a render needs before a page is written: |
| // the manifest, the prose, the assets, the scheme menu, the families, |
| -// the icons and the tokens. |
| +// the icons, the tokens, the weighed release and the stage's scene. |
| func newSite(src fs.FS, mount string) (*site, error) { |
| m, err := example.Load(src) |
| if err != nil { |
| @@ -202,6 +212,12 @@ func newSite(src fs.FS, mount string) (*site, error) { |
| if err := s.readTokens(); err != nil { |
| return nil, fmt.Errorf("gallery: %w", err) |
| } |
| + if s.weighed, err = s.weighRelease(); err != nil { |
| + return nil, fmt.Errorf("gallery: %w", err) |
| + } |
| + if s.scene, err = s.stageScene(); err != nil { |
| + return nil, fmt.Errorf("gallery: %w", err) |
| + } |
| return s, nil |
| } |
| @@ -574,13 +590,28 @@ func reserved(file string) bool { |
| // extraDocuments is every document of one theme and locale that is |
| // neither a page kind's, an example's nor a frame's, keyed by path |
| -// under the directory. Nothing yet: the demo application and the modal |
| -// route land here, under names from reservedDocuments. |
| -func (s *site) extraDocuments(theme, locale string) (map[string][]byte, error) { return nil, nil } |
| +// under the directory: the demo application's four documents and the |
| +// modal route, under their names from reservedDocuments. |
| +func (s *site) extraDocuments(theme, locale string) (map[string][]byte, error) { |
| + out, err := s.demo(theme, locale) |
| + if err != nil { |
| + return nil, fmt.Errorf("the demo application: %w", err) |
| + } |
| + if out["demo-modal.html"], err = s.modal(theme, locale); err != nil { |
| + return nil, fmt.Errorf("the modal route: %w", err) |
| + } |
| + return out, nil |
| +} |
| // extraDocumentNames lists the paths extraDocuments writes, so the |
| // tree gate expects them; each is one of reservedDocuments. |
| -func (s *site) extraDocumentNames() []string { return nil } |
| +func (s *site) extraDocumentNames() []string { |
| + var out []string |
| + for _, view := range demoViews { |
| + out = append(out, demoFile(view)) |
| + } |
| + return append(out, "demo-modal.html") |
| +} |
| // write executes the page template around p and puts the result at |
| // p.File within dir. |
diff --git a/internal/gallery/gallery_test.go b/internal/gallery/gallery_test.go| index d3b8c62..5103f9c 100644 |
| --- a/internal/gallery/gallery_test.go |
| +++ b/internal/gallery/gallery_test.go |
| @@ -188,7 +188,9 @@ var highlighted = regexp.MustCompile(`</?ds-[tavxs]>`) |
| // htmlTab returns the text a reader sees in one state's HTML tab on its |
| // gallery page: the highlighted source with its elements removed and |
| -// its entities decoded. |
| +// its entities decoded. The tab is the first source block after the |
| +// state's marker, read from its <pre class="ds-src">, so the <code> in |
| +// the wrapper line above the block is never taken for it. |
| func htmlTab(t *testing.T, page []byte, name, state string) string { |
| t.Helper() |
| marker := `data-gallery-state="` + name + "/" + state + `"` |
| @@ -197,6 +199,11 @@ func htmlTab(t *testing.T, page []byte, name, state string) string { |
| t.Fatalf("%s/%s: the gallery page has no %s", name, state, marker) |
| } |
| rest := string(page)[i:] |
| + pre := strings.Index(rest, `<pre class="ds-src`) |
| + if pre < 0 { |
| + t.Fatalf("%s/%s: no source block after its marker", name, state) |
| + } |
| + rest = rest[pre:] |
| open := strings.Index(rest, "<code>") |
| shut := strings.Index(rest, "</code>") |
| if open < 0 || shut < open { |
diff --git a/internal/gallery/heights.go b/internal/gallery/heights.go| index 98b761a..2efcb16 100644 |
| --- a/internal/gallery/heights.go |
| +++ b/internal/gallery/heights.go |
| @@ -48,6 +48,7 @@ var previewHeights = map[string]int{ |
| "confirm-form/destructive": 170, |
| "confirm-form/ordinary-default-cancel-wording": 170, |
| "detail-list/four-facts-one-machine-ish": 230, |
| + "demo": 700, |
| "dropdown/a-filter-menu-with-one-choice-applied": 90, |
| "dropdown/menu-idiom": 120, |
| "empty-state/sentence-only": 160, |
| @@ -141,45 +142,45 @@ var previewHeights = map[string]int{ |
| "locale-menu/twelve-languages-current-highlighted": 90, |
| "media-grid/screenshots-a-logo-and-a-banner": 720, |
| "media-grid/select-mode-one-tile-selected": 400, |
| - "meter/row": 100, |
| - "modal/default": 170, |
| - "notice/a-saved-message": 120, |
| - "page-header/title-only": 120, |
| - "page-header/title-subhead-and-a-primary-action": 150, |
| - "pagination/first-page-default-accessible-name": 120, |
| - "pagination/middle-of-a-long-list": 120, |
| - "person/large-the-show-header-size": 100, |
| - "person/name-and-email": 90, |
| - "person/no-initial": 90, |
| - "row-menu/links-a-form-and-a-destructive-item": 90, |
| - "row-menu/links-only-for-a-list-inside-a-selection-form": 90, |
| - "scheme-menu/standalone": 90, |
| - "scheme-menu/three-choices-dark-current": 90, |
| - "seg-tabs/two-sections": 110, |
| - "selbox/default": 90, |
| - "shell-sidebar/default": 260, |
| - "shell-topbar/default": 210, |
| - "signin/a-passkey": 470, |
| - "signin/after-the-link-is-sent": 390, |
| - "signin/after-the-link-is-sent-in-another-browser": 390, |
| - "signin/an-address-that-does-not-look-right": 390, |
| - "signin/asking-for-an-address": 420, |
| - "signin/coming-back-with-an-email-link": 390, |
| - "signin/coming-back-with-keymail": 390, |
| - "signin/email-and-password": 390, |
| - "signin/google-apple-and-the-rest": 310, |
| - "signin/on-the-way-to-keymail": 390, |
| - "signin/when-keymail-could-not-confirm-it": 410, |
| - "signin/when-keymail-was-offered-and-a-link-went-out": 390, |
| - "stat-band/default": 190, |
| - "stat/a-shortened-number": 140, |
| - "stat/a-smaller-number": 140, |
| - "stat/the-big-number": 190, |
| - "stat/when-going-down-is-good": 180, |
| - "stat/when-going-up-is-bad": 180, |
| - "stat/when-you-cannot-say": 180, |
| - "status-pill/row": 100, |
| - "tblock/default": 270, |
| + "meter/row": 100, |
| + "modal/default": 170, |
| + "notice/a-saved-message": 120, |
| + "page-header/title-only": 120, |
| + "page-header/title-subhead-and-a-primary-action": 150, |
| + "pagination/first-page-default-accessible-name": 120, |
| + "pagination/middle-of-a-long-list": 120, |
| + "person/large-the-show-header-size": 100, |
| + "person/name-and-email": 90, |
| + "person/no-initial": 90, |
| + "row-menu/links-a-form-and-a-destructive-item": 90, |
| + "row-menu/links-only-for-a-list-inside-a-selection-form": 90, |
| + "scheme-menu/standalone": 90, |
| + "scheme-menu/three-choices-dark-current": 90, |
| + "seg-tabs/two-sections": 110, |
| + "selbox/default": 90, |
| + "shell-sidebar/default": 260, |
| + "shell-topbar/default": 210, |
| + "signin/a-passkey": 470, |
| + "signin/after-the-link-is-sent": 390, |
| + "signin/after-the-link-is-sent-in-another-browser": 390, |
| + "signin/an-address-that-does-not-look-right": 390, |
| + "signin/asking-for-an-address": 420, |
| + "signin/coming-back-with-an-email-link": 390, |
| + "signin/coming-back-with-keymail": 390, |
| + "signin/email-and-password": 390, |
| + "signin/google-apple-and-the-rest": 310, |
| + "signin/on-the-way-to-keymail": 390, |
| + "signin/when-keymail-could-not-confirm-it": 410, |
| + "signin/when-keymail-was-offered-and-a-link-went-out": 390, |
| + "stat-band/default": 190, |
| + "stat/a-shortened-number": 140, |
| + "stat/a-smaller-number": 140, |
| + "stat/the-big-number": 190, |
| + "stat/when-going-down-is-good": 180, |
| + "stat/when-going-up-is-bad": 180, |
| + "stat/when-you-cannot-say": 180, |
| + "status-pill/row": 100, |
| + "tblock/default": 270, |
| } |
| // previewMobileHeights overrides the 1.25× factor for the examples |
diff --git a/internal/gallery/integration_test.go b/internal/gallery/integration_test.go| new file mode 100644 |
| index 0000000..0de656d |
| --- /dev/null |
| +++ b/internal/gallery/integration_test.go |
| @@ -0,0 +1,185 @@ |
| +package gallery |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| +) |
| + |
| +// The integration gate: what holds only with the four streams in |
| +// together, the shell's rail over Getting started's rows, the URL gate |
| +// over the demo, the modal and the shells in the rail, and the widget |
| +// count with the Overview's demo in it. Each is a preservation of a |
| +// stream's own gate, extended to what another stream added, and each |
| +// was already passing when written; the comment on each names the |
| +// mutation that turns it red. |
| + |
| +// TestTheSidebarLinksGettingStartedExactlyOnce extends |
| +// TestTheSidebarLinksEverythingOnThePageExactlyOnce to Getting started, |
| +// whose rows are another stream's: in every theme and locale the rail's |
| +// fragments into getting-started.html are the page's anchored rows, in |
| +// order, one per file the release ships and the page lists. Mutation: |
| +// assetNav dropping its first item reports the rail one row short on |
| +// every Getting started page. |
| +func TestTheSidebarLinksGettingStartedExactlyOnce(t *testing.T) { |
| + files := render(t, "") |
| + s := newTestSite(t, fiesta.FS(), "") |
| + file := s.fileOf("getting-started") |
| + read := 0 |
| + for _, dir := range dirs() { |
| + theme, locale, _ := strings.Cut(strings.TrimSuffix(dir, "/"), "/") |
| + name := dir + file |
| + page := kindPage(t, files, theme, locale, file) |
| + rail := railOf(t, name, page) |
| + self := s.href(theme, locale, file) |
| + var anchors, fragments []string |
| + for _, m := range anchorMarker.FindAllStringSubmatch(page, -1) { |
| + anchors = append(anchors, m[1]) |
| + } |
| + for _, m := range navHref.FindAllStringSubmatch(rail, -1) { |
| + if strings.HasPrefix(m[1], self+"#") { |
| + fragments = append(fragments, strings.TrimPrefix(m[1], self+"#")) |
| + } |
| + } |
| + view, err := s.buildAssets(s.newPage(theme, locale, "getting-started", file, "")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(view.List) == 0 { |
| + t.Fatalf("%s lists no file", name) |
| + } |
| + if len(fragments) != len(view.List) || strings.Join(fragments, " ") != strings.Join(anchors, " ") { |
| + t.Errorf("%s: the rail links %d rows %v, the page anchors %v and lists %d files", name, len(fragments), fragments, anchors, len(view.List)) |
| + } |
| + read++ |
| + } |
| + if read != len(dirs()) { |
| + t.Fatalf("read %d Getting started pages, want %d", read, len(dirs())) |
| + } |
| +} |
| + |
| +// TestEveryDemoInTheRailResolves extends TestEveryGalleryURLResolves |
| +// to the Demos the rail and the phone index carry, which three streams |
| +// wrote: at a mount, on every page of every directory, the rail's Demos |
| +// are the five shells, the modal route and the demo application, each |
| +// a file the render wrote under that directory, and the Overview's |
| +// phone index lists the same seven; and every link the demo's |
| +// documents and the modal route mark as the gallery's resolves too. |
| +// Mutation: demoHref writing "demo.htm" reports the demo application |
| +// as a file the render did not write, here and in the URL gate. |
| +func TestEveryDemoInTheRailResolves(t *testing.T) { |
| + const mount = "/x" |
| + files := render(t, mount) |
| + s := newTestSite(t, fiesta.FS(), mount) |
| + checked := 0 |
| + for _, dir := range dirs() { |
| + theme, locale, _ := strings.Cut(strings.TrimSuffix(dir, "/"), "/") |
| + want := demoHrefs(t, s, theme, locale) |
| + for _, pk := range s.pageKinds() { |
| + name := dir + pk.File |
| + page := kindPage(t, files, theme, locale, pk.File) |
| + groups := railSections(railOf(t, name, page)) |
| + if len(groups) == 0 { |
| + t.Fatalf("%s: no rail", name) |
| + } |
| + demos := groups[len(groups)-1] |
| + got := map[string]bool{} |
| + for _, m := range navHref.FindAllStringSubmatch(demos.Body, -1) { |
| + got[m[1]] = true |
| + path := strings.TrimPrefix(m[1], mount+"/") |
| + if _, ok := files[path]; !ok || !strings.HasPrefix(path, dir) { |
| + t.Errorf("%s: the Demos link %q, which is not a file of %s", name, m[1], dir) |
| + } |
| + checked++ |
| + } |
| + if !sameKeys(got, want) { |
| + t.Errorf("%s: the Demos are %v, want %v", name, keysOf(got), keysOf(want)) |
| + } |
| + if pk.Kind == "overview" { |
| + m := indexNav.FindStringSubmatch(page) |
| + if m == nil { |
| + t.Fatalf("%s: no phone index", name) |
| + } |
| + rows := map[string]bool{} |
| + for _, l := range navHref.FindAllStringSubmatch(m[2], -1) { |
| + if want[l[1]] { |
| + rows[l[1]] = true |
| + } |
| + } |
| + if !sameKeys(rows, want) { |
| + t.Errorf("%s: the phone index lists the Demos %v, want %v", name, keysOf(rows), keysOf(want)) |
| + } |
| + } |
| + } |
| + for _, extra := range s.extraDocumentNames() { |
| + for _, u := range galleryURLs(t, files[dir+extra]) { |
| + path, _, _ := strings.Cut(strings.TrimPrefix(u, mount+"/"), "#") |
| + if _, ok := files[path]; !ok || !strings.HasPrefix(u, mount+"/") { |
| + t.Errorf("%s%s links %q, which the render did not write", dir, extra, u) |
| + } |
| + checked++ |
| + } |
| + } |
| + } |
| + if checked == 0 { |
| + t.Fatal("no link was checked") |
| + } |
| +} |
| + |
| +// TestEveryWidgetIsFramedAsItsKindSays extends |
| +// TestEveryExampleIsFramedDesktopMobileAndHTML to the Overview's demo, |
| +// another stream's widget in the first stream's markup: on day/en the |
| +// example pages hold one widget per state or per grouped example, each |
| +// with Desktop, Mobile and HTML and its panel, and in every directory |
| +// the Overview holds exactly one more, the demo, at the page width with |
| +// Desktop and Mobile and no HTML; every widget's radios carry a name of |
| +// their own on the page. Mutation: the demo's widget given a Source |
| +// (an HTML panel) reports the Overview's widget as an example's. |
| +func TestEveryWidgetIsFramedAsItsKindSays(t *testing.T) { |
| + files := render(t, "") |
| + s := newTestSite(t, fiesta.FS(), "") |
| + theme := fiesta.ThemeNames()[0] |
| + examples, overviews := 0, 0 |
| + for _, e := range s.m.Examples { |
| + for _, w := range widgetsOf(examplePage(t, files, theme, "en", e.Name)) { |
| + examples++ |
| + var order string |
| + for _, m := range tabClass.FindAllStringSubmatch(w, -1) { |
| + order += m[1] |
| + } |
| + if order != "dmh" || panelOf(w) == "" { |
| + t.Errorf("%s: a widget with tabs %q and panel %v, want Desktop, Mobile, HTML and its panel", e.Name, order, panelOf(w) != "") |
| + } |
| + } |
| + } |
| + for _, dir := range dirs() { |
| + name := dir + "index.html" |
| + page := string(files[name]) |
| + widgets := widgetsOf(page) |
| + if len(widgets) != 1 { |
| + t.Errorf("%s: %d widgets, want the demo alone", name, len(widgets)) |
| + continue |
| + } |
| + overviews++ |
| + w := widgets[0] |
| + var order string |
| + for _, m := range tabClass.FindAllStringSubmatch(w, -1) { |
| + order += m[1] |
| + } |
| + if order != "dm" || panelOf(w) != "" || !strings.Contains(page, `<div class="ds-view ds-view--page" style="`) { |
| + t.Errorf("%s: the demo's widget has tabs %q and panel %v, want a page-width widget with Desktop and Mobile and no HTML", name, order, panelOf(w) != "") |
| + } |
| + names := map[string]int{} |
| + for _, r := range radioInput.FindAllStringSubmatch(page, -1) { |
| + names[r[1]]++ |
| + } |
| + if len(names) != 1 || names["demo-app-0"] != 2 { |
| + t.Errorf("%s: the radios are %v, want the demo's two under its own name", name, names) |
| + } |
| + } |
| + if examples < 100 || overviews != len(dirs()) { |
| + t.Fatalf("%d example widgets and %d Overview widgets read; the walk read too little", examples, overviews) |
| + } |
| + t.Logf("%d example widgets on day/en and the demo on %d Overviews", examples, overviews) |
| +} |
diff --git a/internal/gallery/keyboard_browser_test.go b/internal/gallery/keyboard_browser_test.go| index 1ce7026..9d3019e 100644 |
| --- a/internal/gallery/keyboard_browser_test.go |
| +++ b/internal/gallery/keyboard_browser_test.go |
| @@ -318,7 +318,7 @@ func stageDocument(t *testing.T, m *example.Manifest) (name string, doc []byte) |
| } |
| theme := fiesta.ThemeNames()[0] |
| stage, _ := m.Entry("layout-stage") |
| - framed, err := newTestSite(t, fiesta.FS(), mountPath).frame(slotted, theme, "en", stage) |
| + framed, err := newTestSite(t, fiesta.FS(), mountPath).frame(slotted, theme, "en", stage, "with-signin") |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/gallery/modal.go b/internal/gallery/modal.go| new file mode 100644 |
| index 0000000..0e0747e |
| --- /dev/null |
| +++ b/internal/gallery/modal.go |
| @@ -0,0 +1,101 @@ |
| +package gallery |
| + |
| +import ( |
| + "fmt" |
| + "html/template" |
| + "regexp" |
| + |
| + "amadan.net/rastrillo/fiesta/example" |
| +) |
| + |
| +// The modal route: the modal idiom at the URL the idiom insists on. |
| +// The modal example's frame shows the markup inside a widget; this is |
| +// the same markup as a page of its own, demo-modal.html (modal.html is |
| +// the modal example's page), linked from the Demos group and from that |
| +// page. |
| +// |
| +// The document is the modal example's own, with Rastrillo's three |
| +// deviations, each the difference between a sample and a page that |
| +// exists: the backdrop holds a real screen (a page header and a box) |
| +// instead of a bare heading, so there is something behind the panel; |
| +// the panel's tabs link this page, so switching one keeps the modal |
| +// open rather than landing on a route this tree does not serve; and |
| +// Close returns to the Overview, since the backdrop's screen has no |
| +// address of its own here, which the panel says. The panel stays a |
| +// <dialog open>, never showModal()'d, as the example is. |
| +// |
| +// It is framed as a sample document is: the gallery's assets, noindex, |
| +// frame.js first so a scheme the reader chose comes with them, and the |
| +// padding rule, which leaves a page with a backdrop unpadded. |
| + |
| +// modalHref is the modal route's address. |
| +func (s *site) modalHref(theme, locale string) string { |
| + return s.href(theme, locale, "demo-modal.html") |
| +} |
| + |
| +var ( |
| + // The example's backdrop, a page with one heading. |
| + modalBackdrop = regexp.MustCompile(`(?s)<div rst-backdrop inert>.*?</div>\s*</div>`) |
| + // The panel's tabs and its section, each rewritten whole. |
| + modalNav = regexp.MustCompile(`(?s)<nav>.*?</nav>`) |
| + modalSection = regexp.MustCompile(`(?s)<section>.*?</section>`) |
| +) |
| + |
| +// modal builds one theme and locale's modal route. |
| +func (s *site) modal(theme, locale string) ([]byte, error) { |
| + const name = "modal" |
| + e, ok := s.m.Entry(name) |
| + if !ok { |
| + return nil, fmt.Errorf("the manifest has no %s example to build the modal route from", name) |
| + } |
| + doc, err := example.Document(s.src, name, e.Default) |
| + if err != nil { |
| + return nil, err |
| + } |
| + say := func(id string) string { return template.HTMLEscapeString(s.say(locale, id)) } |
| + self := s.modalHref(theme, locale) |
| + overview := s.href(theme, locale, s.fileOf("overview")) |
| + steps := []struct { |
| + what string |
| + re *regexp.Regexp |
| + with string |
| + }{ |
| + {"the title", titleTag, "<title>" + template.HTMLEscapeString(s.say(locale, "gallery.title", "page", s.say(locale, "gallery.demos.modal"), "theme", theme)) + "</title>"}, |
| + {"the backdrop", modalBackdrop, `<div rst-backdrop inert> |
| +<main rst-page id="main"> |
| +<header rst-page-header> |
| + <div rst-page-header-titles> |
| + <h1>` + say("gallery.modal.settings") + `</h1> |
| + <p rst-page-header-sub>` + say("gallery.modal.sub") + `</p> |
| + </div> |
| +</header> |
| +<div rst-box-head><h2>` + say("gallery.modal.account") + `</h2></div> |
| +<section rst-box><p>` + say("gallery.modal.own") + `</p></section> |
| +</main> |
| +</div>`}, |
| + {"the panel's tabs", modalNav, `<nav> |
| + <a data-gallery-nav href="` + self + `" aria-current="page">` + say("gallery.modal.profile") + `</a> |
| + <a data-gallery-nav href="` + self + `">` + say("gallery.modal.billing") + `</a> |
| + <a data-gallery-nav href="` + self + `">` + say("gallery.modal.notifications") + `</a> |
| + </nav>`}, |
| + {"the panel's section", modalSection, `<section> |
| + <a rst-modal-close data-gallery-nav href="` + overview + `" aria-label="` + say("gallery.modal.close") + `">✕</a> |
| + <h2 id="modal-title">` + say("gallery.modal.profile") + `</h2> |
| + <p>` + say("gallery.modal.body") + `</p> |
| + <p>` + say("gallery.modal.nojs") + `</p> |
| + <p>` + say("gallery.modal.inapp") + `</p> |
| + <p><a rst-btn data-gallery-nav href="` + overview + `">` + say("gallery.back") + `</a></p> |
| + </section>`}, |
| + } |
| + for _, step := range steps { |
| + if n := len(step.re.FindAllStringIndex(doc, -1)); n != 1 { |
| + return nil, fmt.Errorf("%s/%s has %d of %s to rewrite, want one", name, e.Default, n, step.what) |
| + } |
| + doc = step.re.ReplaceAllLiteralString(doc, step.with) |
| + } |
| + framed, err := s.framed(doc, theme, locale, e, nil) |
| + if err != nil { |
| + return nil, fmt.Errorf("demo-modal.html: %w", err) |
| + } |
| + return []byte(framed), nil |
| +} |
diff --git a/internal/gallery/nav.go b/internal/gallery/nav.go| index e265c46..2914c83 100644 |
| --- a/internal/gallery/nav.go |
| +++ b/internal/gallery/nav.go |
| @@ -213,28 +213,23 @@ func (s *site) sectionOverview(p page, section navSection) navItem { |
| } |
| } |
| -// demos is the group at the foot of the rail: the five shells, each |
| -// the layout example's default frame document opened in a tab of its |
| -// own, which is the shell laid out whole as the app it stands for. The |
| -// modal route and the demo application are appended by the task that |
| -// writes those documents. |
| +// demos is the group at the foot of the rail, every entry opening in a |
| +// tab of its own: the five shells, each the layout example's default |
| +// frame document, which is the shell laid out whole as the app it |
| +// stands for; then the modal route and the demo application. |
| func (s *site) demos(p page) navSection { |
| out := navSection{Title: s.say(p.Locale, "gallery.nav.demos")} |
| for _, shell := range shellNames { |
| - name := "layout-" + shell |
| - state, ok := s.m.Default(name) |
| - if !ok { |
| - // The shells are frozen in examples/ and named here; a |
| - // manifest without one is a programming error, as a kind |
| - // fileOf does not know is. |
| - panic("gallery: the manifest has no " + name + " example for the Demos group") |
| - } |
| out.Items = append(out.Items, navItem{ |
| Label: s.say(p.Locale, "gallery.demos.shell", "shell", shell), |
| - Href: s.href(p.Theme, p.Locale, "frames/"+name+"/"+state+".html"), |
| + Href: s.layoutHref(p.Theme, p.Locale, "layout-"+shell), |
| Blank: true, |
| }) |
| } |
| + out.Items = append(out.Items, |
| + navItem{Label: s.say(p.Locale, "gallery.demos.modal"), Href: s.modalHref(p.Theme, p.Locale), Blank: true}, |
| + navItem{Label: s.say(p.Locale, "gallery.demos.app"), Href: s.demoHref(p.Theme, p.Locale), Blank: true}, |
| + ) |
| return out |
| } |
diff --git a/internal/gallery/overview.go b/internal/gallery/overview.go| index 5aff47e..37d437f 100644 |
| --- a/internal/gallery/overview.go |
| +++ b/internal/gallery/overview.go |
| @@ -5,21 +5,32 @@ import ( |
| "html/template" |
| ) |
| -// The Overview: the address every visitor lands on first. Its lead, |
| -// then a route into each of the other pages, read off pageKinds() with |
| -// each row's own blurb, so a page kind appears here the day its row |
| -// lands. The intro opens it; the demo application is a later task's. |
| +// The Overview: the address every visitor lands on first. Its intro; |
| +// then the demo application, framed, because what a first-time reader |
| +// wants to know is what an app built with this looks like; then a |
| +// route into each of the other pages, read off pageKinds() with each |
| +// row's own blurb, so a page kind appears here the day its row lands. |
| const overviewBody = `{{define "ds-body-overview"}}<p class="ds-intro">{{say "gallery.overview.intro"}}</p> |
| -<p class="ds-lead">{{say "gallery.index.lead" "theme" .Theme}}</p> |
| +<section class="ds-demo" id="demo-app"> |
| +<h2 class="ds-sub">{{say "gallery.demo.heading"}}</h2> |
| +<p class="ds-lead">{{say "gallery.demo.lead"}}</p> |
| +{{template "ds-view" .Demo}} |
| +<p class="ds-note"><a data-gallery-nav href="{{.DemoHref}}" target="_blank" rel="noopener">{{say "gallery.demo.open"}}<span class="rst-sr-only"> ({{say "gallery.newtab"}})</span></a>.</p> |
| +</section> |
| <h2 class="ds-sub">{{say "gallery.overview.next"}}</h2> |
| <ul class="ds-routes">{{range .Routes}} |
| <li><a data-gallery-nav href="{{.Href}}">{{.Label}}</a><span>{{.Blurb}}</span></li>{{end}} |
| </ul> |
| {{end}}` |
| -// overviewBody renders the Overview's body for p. |
| +// overviewBody renders the Overview's body for p: the page, with the |
| +// demo's widget and address beside it. |
| func (s *site) overviewBody(p page) (template.HTML, error) { |
| - return s.body(p.Locale, "ds-body-overview", p) |
| + return s.body(p.Locale, "ds-body-overview", struct { |
| + page |
| + Demo previewView |
| + DemoHref string |
| + }{p, s.demoView(p), s.demoHref(p.Theme, p.Locale)}) |
| } |
| // body executes one named body template against data. |
diff --git a/internal/gallery/pages_a11y_browser_test.go b/internal/gallery/pages_a11y_browser_test.go| index fe52546..4b5d4eb 100644 |
| --- a/internal/gallery/pages_a11y_browser_test.go |
| +++ b/internal/gallery/pages_a11y_browser_test.go |
| @@ -23,83 +23,93 @@ import ( |
| // system's explanations: the families' headings and sentences, the |
| // rules at the head of the primitives, and the notes under the states. |
| -// prosePages are the pages scanned in every theme and scheme: the |
| -// three furniture pages, and the four example pages that carry prose |
| -// of every kind. button has notes on its states, dropdown has the |
| -// idiom's note on one state, signin has the summary copy review gave |
| -// it and a note per screen, and format-money has a format's blurb. |
| -var prosePages = []string{"index.html", "tokens.html", "icons.html", "button.html", "dropdown.html", "signin.html", "format-money.html"} |
| +// a11yPages is what the gallery scan reads in every theme, scheme and |
| +// locale it covers: one page per page kind, read off pageKinds() so a |
| +// kind added later is scanned the day it lands (the families' pages, |
| +// form.html among them, included), then the pages no kind covers. |
| +// button carries notes on its states and the widget, dropdown the |
| +// idiom's note on one state, signin the summary, a note per screen and |
| +// the warning callout, format-money a format's blurb, layout-sidebar a |
| +// shell framed at the page width with its open button, modal the |
| +// primitive with its live link; then two documents a reader opens from |
| +// the Demos, the demo application's index and the modal route. |
| +func a11yPages(s *site) []string { |
| + var out []string |
| + for _, pk := range s.pageKinds() { |
| + out = append(out, pk.File) |
| + } |
| + return append(out, "button.html", "dropdown.html", "signin.html", "format-money.html", "layout-sidebar.html", "modal.html", "demo.html", "demo-modal.html") |
| +} |
| -// TestTheGalleryPagesPassAxe: the gallery pages that carry prose, |
| -// scanned with the WCAG 2.2 AA tag set in day, plain and signal with |
| -// data-theme light and dark, and the Arabic index in day. |
| -// Expected: no violations. The rules axe could not decide are logged |
| -// per leg, never silently dropped. |
| +// TestA11yScansTheGallery is the gate over the gallery's own pages, |
| +// which carry the design system's explanations around the samples: |
| +// a11yPages in day, plain and signal, with data-theme light and dark, |
| +// in English and in Arabic, at 1280 (where the rail, the bar and the |
| +// Overview's content are all on the page), with the WCAG 2.2 AA tag |
| +// set. Expected: no violations. The rules axe could not decide are |
| +// logged per leg, never silently dropped. It extends Fiesta's |
| +// TestTheGalleryPagesPassAxe, which scanned seven pages in English and |
| +// the Arabic index, to every page kind and the Demos. The frames' |
| +// documents are TestEveryExamplePassesAxe's. |
| // |
| -// Already passing when written: the pages had no violation to find. |
| -// The check was proved live by a named mutation, a text input with no |
| -// label planted in indexTemplate, which it reported as label on every |
| -// index scan. |
| -func TestTheGalleryPagesPassAxe(t *testing.T) { |
| - rig := harness.New(t, func(string) http.Handler { return newTree(t, nil) }) |
| - ctx, cancel := context.WithTimeout(rig.Context(), 10*time.Minute) |
| +// Preservation of the pages scan, already passing when written. The |
| +// named mutation is .ds-lead's colour set to --rst-line in |
| +// gallery.css, which axe reports as color-contrast on every page with a |
| +// lead. The rail's accessible name removed is not one: two unnamed |
| +// navigation landmarks are axe's landmark-unique, a best-practice rule |
| +// outside the WCAG 2.2 AA tag set this gate runs. |
| +func TestA11yScansTheGallery(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 18*time.Minute) |
| defer cancel() |
| - |
| - type leg struct { |
| - theme string |
| - locale string |
| - pages []string |
| - } |
| - var legs []leg |
| - for _, theme := range fiesta.ThemeNames() { |
| - legs = append(legs, leg{theme, "en", prosePages}) |
| - } |
| - legs = append(legs, leg{fiesta.ThemeNames()[0], "ar", []string{"index.html"}}) |
| + pages := a11yPages(newTestSite(t, fiesta.FS(), mountPath)) |
| scans, failed := 0, 0 |
| - for _, leg := range legs { |
| - name := leg.theme + " " + leg.locale |
| - incomplete := map[string]int{} |
| - start := time.Now() |
| - for _, file := range leg.pages { |
| - dir := leg.theme + "/" + leg.locale + "/" |
| - where := name + " " + file |
| - mustRun(t, ctx, |
| - chromedp.EmulateViewport(1280, 900), |
| - chromedp.Navigate(rig.Origin+href(dir+file)), |
| - chromedp.WaitReady("body"), |
| - ) |
| - // The body's ground is read back after each flip, and the |
| - // two must differ: a dark scan of a page still painted |
| - // light would be the light scan twice. |
| - ground := map[string]string{} |
| - for _, scheme := range []string{"light", "dark"} { |
| - var bg string |
| - mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`document.documentElement.setAttribute("data-theme", %q); getComputedStyle(document.body).backgroundColor`, scheme), &bg)) |
| - ground[scheme] = bg |
| - res, err := axe.Scan(ctx, "", axe.DefaultTags()) |
| - if err != nil { |
| - t.Fatalf("%s %s: %v", where, scheme, err) |
| + for _, theme := range fiesta.ThemeNames() { |
| + for _, locale := range []string{"en", "ar"} { |
| + name := theme + " " + locale |
| + incomplete := map[string]int{} |
| + start := time.Now() |
| + for _, file := range pages { |
| + dir := theme + "/" + locale + "/" |
| + where := name + " " + file |
| + mustRun(t, ctx, |
| + chromedp.EmulateViewport(1280, 900), |
| + chromedp.Navigate(rig.Origin+href(dir+file)), |
| + chromedp.WaitReady("body"), |
| + ) |
| + // The body's ground is read back after each flip, and |
| + // the two must differ: a dark scan of a page still |
| + // painted light would be the light scan twice. |
| + ground := map[string]string{} |
| + for _, scheme := range []string{"light", "dark"} { |
| + var bg string |
| + mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`document.documentElement.setAttribute("data-theme", %q); getComputedStyle(document.body).backgroundColor`, scheme), &bg)) |
| + ground[scheme] = bg |
| + res, err := axe.Scan(ctx, "", axe.DefaultTags()) |
| + if err != nil { |
| + t.Fatalf("%s %s: %v", where, scheme, err) |
| + } |
| + scans++ |
| + for _, id := range res.Incomplete { |
| + incomplete[id]++ |
| + } |
| + failed += report(t, where+" "+scheme, res.Violations) |
| } |
| - scans++ |
| - for _, id := range res.Incomplete { |
| - incomplete[id]++ |
| + if ground["light"] == ground["dark"] { |
| + t.Fatalf("%s: the body paints %s under both schemes, so the flip to dark did not take", where, ground["light"]) |
| } |
| - failed += report(t, where+" "+scheme, res.Violations) |
| } |
| - if ground["light"] == ground["dark"] { |
| - t.Fatalf("%s: the body paints %s under both schemes, so the flip to dark did not take", where, ground["light"]) |
| + var ids []string |
| + for id, n := range incomplete { |
| + ids = append(ids, fmt.Sprintf("%s x%d", id, n)) |
| } |
| + sort.Strings(ids) |
| + t.Logf("%s: %d pages scanned in %s; needs review, axe could not decide: %v", name, len(pages), time.Since(start).Round(time.Second), ids) |
| } |
| - var ids []string |
| - for id, n := range incomplete { |
| - ids = append(ids, fmt.Sprintf("%s x%d", id, n)) |
| - } |
| - sort.Strings(ids) |
| - t.Logf("%s: scanned in %s; needs review, axe could not decide: %v", name, time.Since(start).Round(time.Second), ids) |
| } |
| - if scans == 0 { |
| - t.Fatal("no page was scanned") |
| + if want := len(fiesta.ThemeNames()) * 2 * len(pages) * 2; scans != want { |
| + t.Fatalf("%d scans, want %d", scans, want) |
| } |
| t.Logf("%d scans, %d violations", scans, failed) |
| rig.Screen("main", "the last page") |
diff --git a/internal/gallery/rail_test.go b/internal/gallery/rail_test.go| index 867ce7b..eba98a7 100644 |
| --- a/internal/gallery/rail_test.go |
| +++ b/internal/gallery/rail_test.go |
| @@ -48,7 +48,7 @@ func pageNames(s *site, files map[string][]byte) []string { |
| for _, dir := range dirs() { |
| if dir == fiesta.ThemeNames()[0]+"/en/" { |
| for name := range files { |
| - if strings.HasPrefix(name, dir) && strings.HasSuffix(name, ".html") && !isFrame(name) { |
| + if strings.HasPrefix(name, dir) && strings.HasSuffix(name, ".html") && !isSampleDocument(name) { |
| out = append(out, name) |
| } |
| } |
| @@ -362,7 +362,8 @@ func TestTheRailSaysWhichPageYouAreOn(t *testing.T) { |
| // the anchored elements on the page, in page order; on a family page |
| // the section's entries are exactly the page's own list of examples, |
| // in order. Everything the rail links is a page of this tree, and the |
| -// links out of the gallery's pages are the five shell demos alone. |
| +// links out of the gallery's pages are the Demos alone: the five shell |
| +// demos, the modal route and the demo application. |
| func TestTheSidebarLinksEverythingOnThePageExactlyOnce(t *testing.T) { |
| files := render(t, "") |
| s := newTestSite(t, fiesta.FS(), "") |
| @@ -428,12 +429,12 @@ func TestTheSidebarLinksEverythingOnThePageExactlyOnce(t *testing.T) { |
| t.Errorf("%s: the sidebar links %q, which is not a page of this tree", name, m[1]) |
| continue |
| } |
| - if isFrame(path) { |
| + if isSampleDocument(path) { |
| away++ |
| } |
| } |
| - if want := len(shellNames); away != want { |
| - t.Errorf("%s: the sidebar has %d links out of the gallery's pages, want %d (one per shell demo)", name, away, want) |
| + if want := len(shellNames) + 2; away != want { |
| + t.Errorf("%s: the sidebar has %d links out of the gallery's pages, want %d (one per shell demo, the modal route and the demo application)", name, away, want) |
| } |
| } |
| } |
| @@ -556,8 +557,9 @@ func TestEverySectionOfTheRailRoutesToItsOwnPage(t *testing.T) { |
| } |
| } |
| -// demoHrefs are the five shell demos' addresses in one directory: the |
| -// layout examples' default frame documents. |
| +// demoHrefs are the Demos' addresses in one directory: the five shell |
| +// demos (the layout examples' default frame documents), the modal route |
| +// and the demo application. |
| func demoHrefs(t *testing.T, s *site, theme, locale string) map[string]bool { |
| t.Helper() |
| out := map[string]bool{} |
| @@ -568,15 +570,20 @@ func demoHrefs(t *testing.T, s *site, theme, locale string) map[string]bool { |
| } |
| out[s.href(theme, locale, "frames/layout-"+shell+"/"+state+".html")] = true |
| } |
| + out[s.href(theme, locale, "demo-modal.html")] = true |
| + out[s.href(theme, locale, "demo.html")] = true |
| return out |
| } |
| // TestEveryDemoLinkOpensInANewTab: every link the renderer owns that |
| -// leaves the gallery's pages for a shell demo opens in a new tab, and |
| -// the rail carries one to each of the five on every page. A reader is |
| -// a long way down a page with a filter typed into the rail; losing |
| -// that to look at a demo is a poor trade. The switchers are not in |
| -// this set: a theme or a language is the same page again. |
| +// leaves the gallery's pages for a demo opens in a new tab, and the |
| +// rail carries one to each of the seven on every page: the five shells, |
| +// the modal route and the demo application. The three primitives that |
| +// are pages of their own (the modal, and the sidebar and topbar shell |
| +// idioms) each end with a link to that page, in a new tab, saying so. |
| +// A reader is a long way down a page with a filter typed into the |
| +// rail; losing that to look at a demo is a poor trade. The switchers |
| +// are not in this set: a theme or a language is the same page again. |
| func TestEveryDemoLinkOpensInANewTab(t *testing.T) { |
| files := render(t, "") |
| s := newTestSite(t, fiesta.FS(), "") |
| @@ -584,6 +591,9 @@ func TestEveryDemoLinkOpensInANewTab(t *testing.T) { |
| for _, theme := range fiesta.ThemeNames() { |
| for _, locale := range Locales { |
| demos := demoHrefs(t, s, theme, locale) |
| + if len(demos) != len(shellNames)+2 { |
| + t.Fatalf("%d demos, want %d", len(demos), len(shellNames)+2) |
| + } |
| seen := map[string]int{} |
| pages := 0 |
| for _, name := range pageNames(s, files) { |
| @@ -606,6 +616,19 @@ func TestEveryDemoLinkOpensInANewTab(t *testing.T) { |
| t.Errorf("%s/%s: %s is linked %d times, want at least %d (the rail, on every page)", theme, locale, href, seen[href], pages) |
| } |
| } |
| + // The primitives' own links, on their pages in every |
| + // directory. |
| + newTab := `<span class="rst-sr-only"> (` + template.HTMLEscapeString(s.say(locale, "gallery.newtab")) + `)</span></a>` |
| + for _, live := range []struct{ example, label, href string }{ |
| + {"modal", "gallery.primitives.modal.live", s.href(theme, locale, "demo-modal.html")}, |
| + {"shell-sidebar", "gallery.primitives.sidebar.live", s.href(theme, locale, "frames/layout-sidebar/"+mustEntry(t, "layout-sidebar").Default+".html")}, |
| + {"shell-topbar", "gallery.primitives.topbar.live", s.href(theme, locale, "frames/layout-topbar/"+mustEntry(t, "layout-topbar").Default+".html")}, |
| + } { |
| + want := `<a data-gallery-nav href="` + live.href + `" target="_blank" rel="noopener">` + template.HTMLEscapeString(s.say(locale, live.label)) + newTab |
| + if n := strings.Count(examplePage(t, files, theme, locale, live.example), want); n != 1 { |
| + t.Errorf("%s/%s/%s.html carries %d links %s, want one", theme, locale, live.example, n, want) |
| + } |
| + } |
| } |
| } |
| } |
diff --git a/internal/gallery/started.go b/internal/gallery/started.go| index eba3b91..d5f7849 100644 |
| --- a/internal/gallery/started.go |
| +++ b/internal/gallery/started.go |
| @@ -3,11 +3,8 @@ package gallery |
| import ( |
| "fmt" |
| "html/template" |
| - "runtime" |
| "sort" |
| "strings" |
| - "sync" |
| - "weak" |
| "amadan.net/rastrillo/fiesta" |
| "amadan.net/rastrillo/fiesta/internal/build" |
| @@ -70,25 +67,11 @@ var blurbs = map[string]string{ |
| // the page needs: every file's weight, by name. |
| type release map[string]weight |
| -// releases is each live site's release, built once. The rail asks |
| -// assetNav for the rows on every page of the tree, and a release build |
| -// and forty gzips a page would be most of the render. The site has no |
| -// field for it, so the cache is here, keyed by a weak pointer to the |
| -// site and cleared when the site is collected. |
| -var releases struct { |
| - sync.Mutex |
| - m map[weak.Pointer[site]]release |
| -} |
| - |
| -// release is s's release build, weighed, built on the first call and |
| -// kept while s lives. |
| -func (s *site) release() (release, error) { |
| - key := weak.Make(s) |
| - releases.Lock() |
| - defer releases.Unlock() |
| - if rel, ok := releases.m[key]; ok { |
| - return rel, nil |
| - } |
| +// weighRelease builds the release from s's source and weighs every |
| +// file in it. newSite calls it once and keeps the result on the site: |
| +// the rail asks assetNav for the rows on every page of the tree, and a |
| +// release build and forty gzips a page would be most of the render. |
| +func (s *site) weighRelease() (release, error) { |
| dist, err := build.Dist(s.src, fiesta.Version) |
| if err != nil { |
| return nil, err |
| @@ -97,15 +80,6 @@ func (s *site) release() (release, error) { |
| for name, body := range dist { |
| rel[name] = weigh(body) |
| } |
| - if releases.m == nil { |
| - releases.m = map[weak.Pointer[site]]release{} |
| - } |
| - releases.m[key] = rel |
| - runtime.AddCleanup(s, func(k weak.Pointer[site]) { |
| - releases.Lock() |
| - delete(releases.m, k) |
| - releases.Unlock() |
| - }, key) |
| return rel, nil |
| } |
| @@ -118,10 +92,7 @@ func (s *site) release() (release, error) { |
| // SVGs the Icons page draws, the JSON the strings scripts wrap), or an |
| // error naming it. |
| func (s *site) buildAssets(p page) (assetsView, error) { |
| - rel, err := s.release() |
| - if err != nil { |
| - return assetsView{}, err |
| - } |
| + rel := s.weighed |
| var out assetsView |
| listed := map[string]bool{} |
| add := func(name, blurb string) error { |
diff --git a/internal/gallery/tree_test.go b/internal/gallery/tree_test.go| index 590204a..e901493 100644 |
| --- a/internal/gallery/tree_test.go |
| +++ b/internal/gallery/tree_test.go |
| @@ -51,6 +51,15 @@ func dirs() []string { |
| // isFrame says whether a tree path is a frame document. |
| func isFrame(name string) bool { return strings.Contains(name, "/frames/") } |
| +// isSampleDocument says whether a file of the tree is a sample's own |
| +// document rather than a gallery page: a frame, or one of the extra |
| +// documents (the demo application's four and the modal route), whose |
| +// chrome is a sample's and not the gallery's. A gate on what every |
| +// gallery page carries reads the pages alone. |
| +func isSampleDocument(name string) bool { |
| + return isFrame(name) || reserved(name[strings.LastIndex(name, "/")+1:]) |
| +} |
| + |
| // titleOf is the document's <title> text. |
| func titleOf(t *testing.T, doc node) string { |
| t.Helper() |
| @@ -111,6 +120,7 @@ func TestTreeShapeIsComplete(t *testing.T) { |
| var fixed []string |
| for _, dir := range dirs() { |
| fixed = append(fixed, dir+"index.html", dir+"tokens.html", dir+"icons.html") |
| + fixed = append(fixed, dir+"demo.html", dir+"demo-dashboard.html", dir+"demo-requests.html", dir+"demo-request.html", dir+"demo-modal.html") |
| for _, f := range groupFamilies(m.Examples) { |
| fixed = append(fixed, dir+f.Key+".html") |
| } |
| @@ -143,8 +153,8 @@ func TestTreeShapeIsComplete(t *testing.T) { |
| if len(files) != len(want) { |
| t.Errorf("the tree holds %d files, the seams name %d", len(files), len(want)) |
| } |
| - t.Logf("%d files: %d directories of %d pages, %d example pages and %d frames each; %d assets twice; the root index", |
| - len(files), len(dirs()), len(s.pageKinds()), len(m.Examples), len(fixed)/len(dirs())-3-len(groupFamilies(m.Examples))-len(m.Examples), len(names)) |
| + t.Logf("%d files: %d directories of %d pages, %d example pages, %d frames and %d other documents each; %d assets twice; the root index", |
| + len(files), len(dirs()), len(s.pageKinds()), len(m.Examples), len(fixed)/len(dirs())-3-5-len(groupFamilies(m.Examples))-len(m.Examples), len(s.extraDocumentNames()), len(names)) |
| } |
| // TestTheAssetDeclarationDrivesTheTree: the assets the tree ships are |
| @@ -512,7 +522,7 @@ func TestEveryPageIsAWholeLocalisedDocument(t *testing.T) { |
| if n := len(doc.find(func(n node) bool { return n.n.Data == "title" })); n != 1 { |
| t.Errorf("%s has %d <title> elements", name, n) |
| } |
| - if isFrame(name) { |
| + if isSampleDocument(name) { |
| frames++ |
| continue |
| } |
| @@ -615,7 +625,7 @@ func TestEveryPagesH1IsItsOwnTitle(t *testing.T) { |
| files := render(t, "") |
| checked := 0 |
| for name, body := range files { |
| - if !strings.HasSuffix(name, ".html") || isFrame(name) { |
| + if !strings.HasSuffix(name, ".html") || isSampleDocument(name) { |
| continue |
| } |
| doc := parse(t, body) |
| @@ -866,7 +876,7 @@ func TestAnExampleDocumentFramesLikeASeededOne(t *testing.T) { |
| s := newTestSite(t, fiesta.FS(), "/x") |
| e := mustEntry(t, "button") |
| doc := exampleDocument("A test", []string{"light.js"}, "body { padding: 0 }", `<p><a href="/orders">Orders</a></p>`) |
| - framed, err := s.frame(doc, "plain", "ar", e) |
| + framed, err := s.frame(doc, "plain", "ar", e, e.Default) |
| if err != nil { |
| t.Fatalf("frame: %v", err) |
| } |
| @@ -898,7 +908,7 @@ func TestAnExampleDocumentFramesLikeASeededOne(t *testing.T) { |
| } |
| bare := strings.Replace(strings.Replace(doc, `<link rel="stylesheet" href="../../css/tokens.css">`+"\n", "", 1), `<link rel="stylesheet" href="../../css/themes/day.css">`+"\n", "", 1) |
| bare = strings.Replace(bare, `<script defer src="../../js/light.js"></script>`+"\n", "", 1) |
| - if _, err := s.frame(bare, "plain", "ar", e); err == nil { |
| + if _, err := s.frame(bare, "plain", "ar", e, e.Default); err == nil { |
| t.Error("frame accepted a document with nothing in its head to put frame.js before") |
| } |
| } |
diff --git a/internal/gallery/view.css b/internal/gallery/view.css| index 077ef25..5b84651 100644 |
| --- a/internal/gallery/view.css |
| +++ b/internal/gallery/view.css |
| @@ -28,7 +28,7 @@ |
| what a reader sees is not what Copy puts on the clipboard. isolate |
| keeps a name inside a sentence (the wrapper line's) from reordering |
| the words around it. */ |
| -.ds-src, .ds-wrap .rst-mono { direction: ltr; text-align: start; unicode-bidi: isolate; } |
| +.ds-src, .ds-wrap code { direction: ltr; text-align: start; unicode-bidi: isolate; } |
| /* The five token colours, from theme tokens so they follow theme and |
| scheme, each at 4.5:1 or better against --rst-surface, the block's |
| background, in every theme and both schemes; the test reads these |
diff --git a/internal/gallery/view.go b/internal/gallery/view.go| index e3f1b84..8f8831d 100644 |
| --- a/internal/gallery/view.go |
| +++ b/internal/gallery/view.go |
| @@ -180,14 +180,12 @@ func (s *site) frameTitle(locale string, e example.Entry, st example.State) stri |
| } |
| // wrapLine is the sentence above a wrapped sample's code, naming the |
| -// container with its opening markup in the mono face the gallery gives |
| -// every identifier (a span with rst-mono, as the rail's items wear it, |
| -// rather than a <code>, so the state's one <code> on the page is the |
| -// block a reader copies). The sentence is the page's, escaped; the |
| -// markup goes in after, so the words around it can be any language's. |
| +// container with its opening markup in a <code>, as Rastrillo's line |
| +// does. The sentence is the page's, escaped; the markup goes in after, |
| +// so the words around it can be any language's. |
| func (s *site) wrapLine(locale, open string) template.HTML { |
| sentence := template.HTMLEscapeString(s.say(locale, "gallery.wrap")) |
| - code := `<span class="rst-mono">` + template.HTMLEscapeString(open) + "</span>" |
| + code := "<code>" + template.HTMLEscapeString(open) + "</code>" |
| return template.HTML(strings.Replace(sentence, "{wrapper}", code, 1)) |
| } |
diff --git a/internal/gallery/view_browser_test.go b/internal/gallery/view_browser_test.go| index 4449306..6d07a0a 100644 |
| --- a/internal/gallery/view_browser_test.go |
| +++ b/internal/gallery/view_browser_test.go |
| @@ -655,8 +655,8 @@ func dsw(t *testing.T, where, raw string) float64 { |
| // are the same width; and --ds-k is the stage over the virtual width, |
| // clamped. Both width classes are swept. The control that makes the |
| // monotone assertion able to fail, a window that grows while its stage |
| -// shrinks, is the rail arriving at 800px: it is required once the page |
| -// has a rail, and logged as absent until the shell lands. |
| +// shrinks, is the rail arriving at 800px, and it is required: every |
| +// gallery page is the sidebar shell. |
| func TestThePreviewDefaultIsMonotoneInStageWidth(t *testing.T) { |
| rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| ctx, cancel := context.WithTimeout(rig.Context(), 240*time.Second) |
| @@ -709,10 +709,10 @@ func monotoneSweep(t *testing.T, ctx context.Context, rig *harness.Rig, file str |
| switch { |
| case grew.VW == 0 || shrank.VW == 0: |
| t.Fatalf("the sweep is missing the 700px/800px pair") |
| - case shrank.Rail && shrank.Stage >= grew.Stage: |
| - t.Fatalf("the control is gone: with a rail on the page the stage is %.1fpx in a 700px window and %.1fpx in an 800px one, so no window grows while its stage shrinks", grew.Stage, shrank.Stage) |
| case !shrank.Rail: |
| - t.Logf("no rail on this page yet: the stage is %.1fpx at 700px and %.1fpx at 800px, so the monotone assertion below has no window that grows while its stage shrinks to catch; the shell's rail supplies it", grew.Stage, shrank.Stage) |
| + t.Fatalf("no rail on the page at 800px: the gallery is the sidebar shell, and its rail arriving is the control this drive needs") |
| + case shrank.Stage >= grew.Stage: |
| + t.Fatalf("the control is gone: with the rail on the page the stage is %.1fpx in a 700px window and %.1fpx in an 800px one, so no window grows while its stage shrinks", grew.Stage, shrank.Stage) |
| default: |
| t.Logf("control: the stage shrinks from %.1fpx to %.1fpx as the window grows from 700px to 800px; the rail arrives", grew.Stage, shrank.Stage) |
| } |
| @@ -742,37 +742,3 @@ func monotoneSweep(t *testing.T, ctx context.Context, rig *harness.Rig, file str |
| } |
| } |
| } |
| - |
| -// TestTheFrameMirrorsRightToLeft: right to left, the shell's grid puts |
| -// the rail on the right and the bar over main on the left, with no rule |
| -// of the gallery's: the grid lines are numbered, not sided. Port of |
| -// Rastrillo's drive, on an example page of day/ar at 1280px. |
| -func TestTheFrameMirrorsRightToLeft(t *testing.T) { |
| - rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| - ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| - defer cancel() |
| - where := "day/ar form-layout at 1280px" |
| - openPage(t, ctx, where, pageURL(rig, "ar", "form-layout.html"), 1280, 900) |
| - var r struct { |
| - RailLeft, MainRight, BarBottom, MainTop, BarLeft, BarRight, MainLeft, BrandLeft, ControlsRight float64 |
| - } |
| - at(t, ctx, `(() => { |
| - const b = s => document.querySelector(s).getBoundingClientRect(); |
| - const rail = b("[rst-shell-rail]"), bar = b(".ds-top"), main = b("main"); |
| - const brand = b(".ds-top__brand"), controls = b(".ds-top__controls"); |
| - return JSON.stringify({RailLeft: rail.left, MainRight: main.right, BarBottom: bar.bottom, MainTop: main.top, BarLeft: bar.left, BarRight: bar.right, MainLeft: main.left, BrandLeft: brand.left, ControlsRight: controls.right}); |
| - })()`, &r) |
| - if r.RailLeft < r.MainRight-0.5 { |
| - t.Errorf("%s: the rail starts at %.1fpx, left of main's right edge %.1fpx; it belongs on the right", where, r.RailLeft, r.MainRight) |
| - } |
| - if r.BarBottom > r.MainTop+0.5 || r.BarLeft < r.MainLeft-0.5 || r.BarRight > r.MainRight+0.5 { |
| - t.Errorf("%s: the bar [%v…%v, bottom %v] is not above main [%v…%v, top %v]", where, r.BarLeft, r.BarRight, r.BarBottom, r.MainLeft, r.MainRight, r.MainTop) |
| - } |
| - // The brand is first in source order and justify-content: |
| - // space-between puts the first child at the inline start, which in |
| - // Arabic is the bar's right side, not its left: a rule keyed to |
| - // "left" rather than to the writing direction would swap these two. |
| - if r.BrandLeft < r.ControlsRight-0.5 { |
| - t.Errorf("%s: the brand's left edge is at %.1fpx, not right of the controls' right edge %.1fpx; the brand belongs at the inline start and the controls at the end", where, r.BrandLeft, r.ControlsRight) |
| - } |
| -} |
diff --git a/internal/gallery/view_test.go b/internal/gallery/view_test.go| index 61e22f9..d00e3f4 100644 |
| --- a/internal/gallery/view_test.go |
| +++ b/internal/gallery/view_test.go |
| @@ -243,7 +243,7 @@ func TestTheHTMLTabNeverCarriesTheWrapper(t *testing.T) { |
| if err != nil || open == "" { |
| t.Fatalf("%s: wrapperMarkup(%q) = %q, %v", e.Name, e.Wrapper, open, err) |
| } |
| - line := `<p class="ds-wrap">Put this inside <span class="rst-mono">` + template.HTMLEscapeString(open) + `</span>.</p>` |
| + line := `<p class="ds-wrap">Put this inside <code>` + template.HTMLEscapeString(open) + `</code>.</p>` |
| if n := strings.Count(page, line); n != len(e.States) { |
| t.Errorf("%s: %d widgets say %q, want one per state (%d)", e.Name, n, line, len(e.States)) |
| } |
| @@ -375,7 +375,8 @@ func TestAGroupedPartialNeedsNoWrapper(t *testing.T) { |
| func TestAGroupedNoteNamesItsStateApart(t *testing.T) { |
| files := render(t, "") |
| p := loadedProse(t) |
| - m := manifest(t) |
| + s := newTestSite(t, fiesta.FS(), "") |
| + m := s.m |
| theme := fiesta.ThemeNames()[0] |
| notes, blocks := 0, 0 |
| for _, locale := range Locales { |
| @@ -387,12 +388,15 @@ func TestAGroupedNoteNamesItsStateApart(t *testing.T) { |
| page := examplePage(t, files, theme, locale, name) |
| for _, st := range e.States { |
| blocks++ |
| - open := `<div class="ds-group-note" id="` + st.ID + `" data-ds-anchor data-gallery-state="` + name + "/" + st.ID + `"><b class="ds-note__state">` + template.HTMLEscapeString(st.Label) + "</b>\n" |
| + // The label and the note in the page's language, read |
| + // through the lookups the page renders them with. |
| + label := s.label(locale, e, st) |
| + open := `<div class="ds-group-note" id="` + st.ID + `" data-ds-anchor data-gallery-state="` + name + "/" + st.ID + `"><b class="ds-note__state">` + template.HTMLEscapeString(label) + "</b>\n" |
| if !strings.Contains(page, open) { |
| t.Errorf("%s/%s %s: no block names the %s state apart", theme, locale, name, st.ID) |
| continue |
| } |
| - note := p.Examples[name].States[st.ID] |
| + note := s.proseIn(locale, "examples."+name+".states."+st.ID, p.Examples[name].States[st.ID]) |
| if note == "" { |
| if strings.Contains(page, open+`<p class="ds-note">`) { |
| t.Errorf("%s/%s %s/%s has a note and the prose has none", theme, locale, name, st.ID) |
| @@ -403,7 +407,7 @@ func TestAGroupedNoteNamesItsStateApart(t *testing.T) { |
| if !strings.Contains(page, open+`<p class="ds-note">`+template.HTMLEscapeString(note)+"</p>\n</div>") { |
| t.Errorf("%s/%s %s/%s: the note does not follow its state on a line of its own", theme, locale, name, st.ID) |
| } |
| - if joined := template.HTMLEscapeString(st.Label + ": " + note); strings.Contains(page, joined) { |
| + if joined := template.HTMLEscapeString(label + ": " + note); strings.Contains(page, joined) { |
| t.Errorf("%s/%s %s/%s: the note is joined to its state as %q", theme, locale, name, st.ID, joined) |
| } |
| } |
| @@ -465,6 +469,13 @@ func TestEveryPreviewIsAFileOneFrameNames(t *testing.T) { |
| // order: the formatted body without its wrapper for a component, the |
| // whole formatted document for a layout, and one block per state for |
| // a grouped example. In English and in Arabic. |
| +// |
| +// The expectation is the English sample on both pages, which holds |
| +// only while the samples are untranslated. When the samples go through |
| +// localMarkup in the page's language, the Arabic leg's want has to be |
| +// the same source through localMarkup for ar (and format-money's the |
| +// money table's markup); until then this is the one test here that |
| +// assumes English on a translated page, on purpose. |
| func TestTheTextOnScreenIsTheTextCopied(t *testing.T) { |
| files := render(t, "") |
| m := manifest(t) |
| @@ -557,7 +568,7 @@ func TestEveryFrameTitleIsUniqueOnThePage(t *testing.T) { |
| titleAttr := regexp.MustCompile(`<iframe class="ds-view__frame"[^>]*\stitle="([^"]*)"`) |
| frames := 0 |
| for name, body := range files { |
| - if !strings.HasSuffix(name, ".html") || isFrame(name) { |
| + if !strings.HasSuffix(name, ".html") || isSampleDocument(name) { |
| continue |
| } |
| seen := map[string]int{} |