Pattern library¶
The storefront's shared fragments (pagination, the sort dropdown, the facet rail, the mini basket ...) rendered from JSON fixtures, as any Nova-family theme renders them, through the real Thymeleaf setup. It has two uses:
- Looking:
/system/patterns(superadmin) shows each pattern's cases in one theme or all of them, at 360, 768 or 1280 pixels wide. The admin's own components are at/admin/styleguide(Admin UI components). - Proving a refactor changed nothing: the same pages can be exported to a folder with no
running stack, screenshotted, and compared pixel by pixel with a later commit's
(
dev-scripts/ui-snapshots.sh). Turning theme forks into shared components is checked this way.
There is no Storybook. It can't render Thymeleaf without a server of its own, it would bring a Node
toolchain into the build, and its stories would skip the things that matter here: theme
resolution, #shopMoney, #shopDates and the message bundle.
How a page is rendered¶
PatternRenderer (web-mvc, render.patterns) has a private Thymeleaf engine, as
EmailTemplateRenderer does for emails:
- The theme is a parameter of each render.
PatternTemplateResolveris the storefront's four legs (override, base, second base, default theme; see Theme resolution), but it reads the theme from the render's template resolution attributes instead of the request's site, asEmailTemplateResolverdoes. The attributes are part of Thymeleaf's cache key, so cached templates never cross themes. After the four legs comes a theme-less one, as on the storefront (Spring Boot's own resolver), so a name that starts with a theme folder (~{nova/extensions/hero/slots :: …}) resolves as written. NoSiteContextis read, built or changed: one engine renders every theme on any request, or with no request at all. - Themes are the shipped records in
commerce-core/src/main/resources/initialisation/templates.json(the file a new platform installs its themes from), Nova and every theme whose chain reaches Nova: nova, brock, depot, pop, atelier, spec, ridge, bistro, diorama. Not the database's, so a render is the same everywhere. - The theme's real head. The page is
web-mvc/src/main/resources/pattern-library/frame.html: the theme's ownfragment/head(so exactly the stylesheets, fonts and scripts a shop on that theme loads, Bistro's extra assets included), then the pattern's fragments in a<main>with 16px of padding. - The app's dialects and the shipped messages. In the app it takes every
IDialectbean, as Spring Boot's engine does. Messages aremessages.propertieswithout any shop's Site Text, inen-GB. Prices are GBP with the symbol style, times Europe/London (a fixture can setshopCurrency,shopCurrencySymbolorshopTimeZonein its model). siteContextis a stand-in map:siteName("Example Shop"),templateTheme(the theme record, for the head's asset paths),productImagePath,contextProperties. A fixture'ssiteobject adds to it. It is never a realSiteContext, and no tenant-aware code sees it.- As on the storefront, templates have no bean access (
${@bean}), and@{/path}links are the path itself.
Fixtures¶
One file per pattern in web-mvc/src/main/resources/patterns/<id>.json. The file name is the id
(lower-case words joined by hyphens).
{
"title": "Pagination",
"description": "Page links under a listing.",
"fragments": ["fragment/pagination :: pagination(${pagParams})"],
"widths": [360, 768, 1280],
"model": { "variables": "every case gets" },
"cases": [
{
"id": "first-page",
"title": "First page",
"description": "Optional: what this case shows.",
"model": {
"pagParams": {"$type": "PaginationParams", "currentPage": 1, "enabled": true}
}
}
]
}
fragments: fragment expressions, theme-relative as a template writes them. They may use model variables and message expressions (category-nav(${categoryNav}, #{search.suggest.categories})). An entry can be{"expression": "...", "optional": true}: it is skipped for a theme whose template doesn't declare that fragment, decided by the first file along the theme's chain (the Brock family'smini-basket-content, which Nova has no equivalent of).model: plain JSON. Objects become maps, which templates read as${user.loginName}. A case's model is laid over the pattern's.$type: where a template calls a method or needs a real class (nav.emptyon aCategoryNavModel,order.collectionTag(id)on aDOrder), an object names its type. Only the types inFixtureTypesare allowed, by short name; a fixture never names a class.
$type |
Binds to | Notes |
|---|---|---|
PaginationParams |
PaginationParams |
the fields as PaginationTools sets them |
SortOptionsModel |
SortOptionsModel |
|
CategoryNavModel |
CategoryNavModel |
|
FacetRailModel |
FacetRailModel |
list each group once in groups; visibleGroups / moreGroups follow each group's more |
Product, Price |
the domain classes | |
DisplayProduct |
DProduct |
{"product": {...}, "variant": {...}, "inventory": 5}; inventory -1 is unlimited |
DisplayOrder |
DOrder |
{"lines": [{"quantity": 2, "inventory": 10, "product": {...}, "price": {...}}]}, built as DisplayTools builds the header's display order |
MegaMenuViewAllLink |
MegaMenuViewAllLink |
{"enabled": true, "label": "View all {0}", "position": "last"}, every field optional: the Menu Generator's viewAllLink (templates call label(name) on it) |
JsonComponent |
the JSON Component extension's model | {"definition": {"version": "1.0", "layout": "contained", "component": {...}}, "dataSources": {"picks": [Product, ...]}}: the document a merchant saves, parsed by the real JsonComponentParser with the Nova token mapping; each data source is a list of products |
LanguageSite |
LanguageSites.LanguageSite |
{"locale": "es-ES", "country": "ES", "name": "Español", "flagEmoji": "…", "href": "https://es.shop.example.com/", "current": false}: an entry of languageSites / currentLanguage (templates call code() on it) |
Page |
Page |
a content page, for the page shell (metaDescriptionOr(...) is a method) |
MerchandisingNode |
MerchandisingNode |
a category, for the category page shell (metaDescription() is a method) |
To allow another type, add it to FixtureTypes and to this table.
frame(optional):"auth"for the standalone account pages (sign in, password reset, messaging confirmations), which have their own head and no storefront chrome. The first fragment is then the page's head (fragment/auth-head :: authHead(...), as the theme resolves it) and the rest go in the pages'.auth-container(pattern-library/auth-frame.html). A fragment can pick part of a page by markup selector (registration :: #login). Leave it out for the storefront frame.- Specimens: a fragment under
pattern-library/specimens/(next to the frame, outside the theme folders) is fixture-only markup that lays out many instances of a component from the model:pattern-library/specimens/buttons :: buttons(${sets})draws rows of buttons, and:: themed(${sets})draws the rows listed for the theme being rendered, from a map keyed by theme folder. No storefront template includes a specimen. frameCss(optional): CSS for the frame only, to show something that would open on click (the mini basket's dropdown).patternScripts(optional, in a model): file names in the theme's js folder that the page a fragment lives on loads outside the head, added after the fragments. The facet rail's long group sets["facet-sheet.js"], as a listing page loads it, so the case shows the group folded with its "Show all"; itslong-group-no-scriptcopy leaves it out, to show the rail when the script is missing (every value listed).- Example data only: invented products and names,
example.comaddresses. Product images aredata:URIs (flat colour squares), so pages need no media.
Adding a pattern¶
- Read the fragment in Nova and in each theme that forks it, and the controller or interceptor that puts its model together, so the fixture's model has the shape the templates read.
- Write
patterns/<id>.jsonwith a case for each state worth seeing: empty, typical, the longest text, the extremes (first and last page, a large count). - Run
PatternLibraryRenderTest. It renders every case of every pattern as every theme and fails on an exception or an unresolved message (??key_en_GB??). This is the safety net for shared components: a fragment one theme breaks fails here, for that theme. - Look at it on
/system/patterns, or export and shoot it.
Before/after screenshots¶
dev-scripts/ui-snapshots.sh runs everything in containers: the export in the Maven build
container (docker-compose-dev.yml's build service, against the checked-out worktree), the
screenshots and comparison in a pinned Playwright image (mcr.microsoft.com/playwright:v1.49.0-jammy
by digest, with the [email protected] npm package installed once into the
badger-ui-snapshots-node volume without install scripts).
# on the base commit
./dev-scripts/ui-snapshots.sh snap before # export + shoot into target/ui-snapshots/before
# on the change
./dev-scripts/ui-snapshots.sh snap after
./dev-scripts/ui-snapshots.sh compare target/ui-snapshots/before/png target/ui-snapshots/after/png target/ui-snapshots/report
The steps can also be run on their own: export <dir>, shoot <export dir> <png dir>,
compare <before> <after> <report dir>.
- export runs
PatternExportTestwithPATTERNS_OUTset, writing<theme>/<pattern>/<case>.html, amanifest.json(every page and its widths) andassets/: the stylesheets, scripts and fonts the pages load, copied from the build output, with whatever their CSS loads in turn. Asset URLs become relative paths, so the folder is self-contained. Fonts are self-hosted under/vendor(Fontsource packages), so nothing comes from a CDN. The test fails if a page loads an asset the build doesn't have. - shoot serves the export over HTTP inside the container (Chromium won't load web fonts from
file://pages) and takes a full-height screenshot of each page at each of its widths, viewport height 900, once fonts have loaded, with animations stopped and the caret hidden:<theme>/<pattern>/<case>@<width>.png. -
compare decodes both sides in headless Chromium and counts the pixels that differ. It writes
report.mdandreport.html(before, after and a diff image for each changed screenshot) and exits 1 if any screenshot differs by more thanUI_SNAPSHOT_THRESHOLDpercent of its pixels (default 0, so any change), changed size, or exists on one side only.UI_SNAPSHOT_TOLERANCE(default 0) lets each colour channel move that much before a pixel counts as different. -
styles explains a changed screenshot: it loads the same page from two exports and prints every computed style that differs, element by element under a root selector (
styles <before pages> <after pages> depot/pagination/middle-page.html '#pagination-wrapper'). Elements are paired in document order, so renamed classes are fine. A screenshot that changed with no computed-style difference and identical HTML is rendering noise (a few pixels of anti-aliasing, seen on Nova's facet rail); re-shoot to confirm.
Two runs of the same commit usually give byte-identical screenshots. On a laptop with the Maven cache warm, for 9 themes, 8 patterns, 24 cases (216 pages, 648 screenshots): export about 1 minute (most of it the build), shoot about 35 seconds, compare 1 to 2 seconds.
Differences are bugs unless the change means them; list the intended ones in the merge request.
Behaviour checks¶
Screenshots show the closed state. A change to storefront script (a theme's js/<theme>.js, or the
shared nova/js/ui-components.js that drives the menu, the phone drawer and the mini basket) is
checked by driving the header the way a shopper does and comparing what happened:
# after snap before / snap after, as above
./dev-scripts/ui-snapshots.sh behave target/ui-snapshots/before/pages target/ui-snapshots/before/behaviour
./dev-scripts/ui-snapshots.sh behave target/ui-snapshots/after/pages target/ui-snapshots/after/behaviour
./dev-scripts/ui-snapshots.sh behave-compare target/ui-snapshots/before/behaviour target/ui-snapshots/after/behaviour
- behave (
dev-scripts/ui-snapshots/behave.cjs, in the same Playwright container) opens every theme'sheader-menuspage (patterns/header-menus.json: the theme's wholefragment/nav, with a collection menu of two drop-downs and a basket of two lines). At 1280 pixels it clicks a menu item, tabs into its panel, presses Escape, opens a second item, clicks elsewhere, opens items by hovering (and clicks the one hover opened), then opens the mini basket, tabs round it 30 times, presses Escape and uses its close button. At 360 pixels it opens the drawer, tabs round it, opens a sub-menu, tabs into it, presses Escape, uses the close button, and widens the window with the drawer open. After each step it writes downaria-expandedon each control, whether each panel is open and visible, the page lock class on<html>and where focus is, into<result dir>/behaviour.json. A theme without a part (Nova, Ridge and Diorama have no.nova-menuor mini basket) logs that. - behave-compare prints every step whose record differs and exits 1 if any did. Nothing is asserted on its own: the commit before is the expectation for the commit after, so a difference is either meant (list it in the merge request) or a regression.
Contrast check¶
The contrast of the text each page renders, in every theme:
./dev-scripts/ui-snapshots.sh export target/ui-snapshots/now/pages
./dev-scripts/ui-snapshots.sh contrast target/ui-snapshots/now/pages target/ui-snapshots/now/contrast
contrast (dev-scripts/ui-snapshots/contrast.cjs) loads every page at 1280 pixels and checks
every visible element with text of its own: its colour, faded by its own and its ancestors'
opacity, against the background colours painted under its centre (the elements stacked there,
their ::before and ::after washes included). The minimum is WCAG's: 4.5:1, 3:1 for large text
(24px, or 18.66px bold) and for a lone symbol (×, →). Disabled, busy and loading controls, a
pager's inactive items and aria-hidden decoration are skipped; text over an image, a video or a
gradient is counted as not measured. <result dir>/contrast.md groups the misses by theme,
element and colours, worst first, and the command exits 1 if there are any.
A miss that is the fixture's rather than the storefront's (the JSON colour swatch, which shows every
text colour on the page ground; a component shown outside the band that styles it) is listed in
dev-scripts/ui-snapshots/contrast-exempt.txt with its reason. Exempt misses are reported apart and
don't fail. Add to that file only for a fixture; a real miss is fixed in the theme.
Accessibility check¶
Everything contrast doesn't cover (names, roles, ARIA, headings, target sizes, links), and whether motion respects the reader's settings:
./dev-scripts/ui-snapshots.sh export target/ui-snapshots/now/pages
./dev-scripts/ui-snapshots.sh a11y target/ui-snapshots/now/pages target/ui-snapshots/now/a11y
a11y (dev-scripts/ui-snapshots/a11y.cjs) runs axe-core
on every page at each of its widths (360, 768 and 1280 pixels), with the WCAG 2.0, 2.1 and 2.2 A
and AA rule tags and axe's best-practice rules. axe-core is pinned to an exact version in
dev-scripts/ui-snapshots/lib.sh, together with the sha512 of its npm tarball:
install-deps.sh downloads the tarball in the container, checks it, and only then installs it (no
install scripts). Nothing runs on the host.
Rules that are about the fixture rather than the component are switched off, each with its reason
in a11y.cjs: color-contrast (the contrast check does it better), landmark-one-main,
page-has-heading-one and region (a case is a fragment, without the page layout around it), and
the three landmark rules (landmark-main-is-top-level, landmark-no-duplicate-main,
landmark-unique) that fire on the <main> the harness wraps around a shell case, which has its own.
Reading the report. <result dir>/a11y.md has the counts by rule, by theme and by component,
how the run compares with the baseline, and then every finding grouped by rule and component, with
the element's selector, the page and the widths that show it. a11y.json has the same findings
with axe's help URL, the element's HTML and its fix hint. A finding is counted once per theme,
page, rule and element, whatever the number of widths showing it.
Reduced motion. Each page is also loaded at 1280 pixels with prefers-reduced-motion: reduce.
Any CSS animation or transition longer than 100ms that is running after load, or after hovering
each of the first 40 interactive elements, is a finding (reduced-motion). Nova's stylesheet
switches motion off for every Nova-family theme (see Foundations), so the
count is 0, and it is in the ratchet like an axe rule: a new animation or transition that runs under
reduced motion fails the run. Before that stylesheet rule the count was about 970 and moved by a few
between runs, because the hover catches transitions mid-flight; at 0 it is steady.
The baseline. dev-scripts/ui-snapshots/a11y-baseline.json holds the count for each rule (axe's
rules and reduced-motion) and component, across themes. A baseline written before motion was
ratcheted has no reduced-motion counts: they are taken as 0, so any motion finding shows as a new
pair. The command exits 1 if any count rises above it or a rule/component
pair appears that isn't in it, so a change can't make accessibility worse. When counts drop, the
command says so; lower the baseline in the same merge request:
The baseline is a ratchet, not a target: every entry in it is a real finding to fix, and a fix should end with a lower baseline.
Exempting. A finding that is the fixture's, not the storefront's, goes in
dev-scripts/ui-snapshots/a11y-exempt.txt with its reason, as in contrast-exempt.txt: a page
glob, an axe rule id (or reduced-motion, or *), and a substring of the element's selector (or
*). Exempt findings are listed apart in the report and are not counted. A real finding is fixed in
the theme, not exempted.
In CI¶
When a feature branch changes web-mvc/src/main/resources/templates/,
web-mvc/src/main/resources/resources/settBuilder/themes/, web-mvc/src/main/resources/patterns/
or dev-scripts/ui-snapshots/ compared with development, its pipeline (the one its merge
request checks, beside the unit and integration tests) gets two more jobs. pattern-export runs PatternExportTest in the Maven image and keeps the export as an
artifact; pattern-audit runs the contrast and accessibility checks on it in the same pinned
Playwright image the script uses (dev-scripts/ui-snapshots/ci.sh, which does what ui-snapshots.sh
does without docker). The job fails if any text misses contrast, or an axe count rises above
a11y-baseline.json. Tags and development/master pipelines don't run them, nor does a commit
with --skip-test in its message. Because the branch as a whole is compared with development,
every later push to it runs them too, even one that touches none of those files.
The job log ends with a summary: the contrast: and a11y: lines, a RISE line for each count
above the baseline, and which check failed. The reports are kept for a week and linked from the
merge request as "Pattern audit": contrast/contrast.md and a11y/a11y.md as described above,
with a11y.json and each check's full log. To reproduce a failure, run the same commands locally
on the branch; when counts have dropped, lower the baseline in the merge request as above.
Limits¶
- A case shows the fragment on its own, inside the theme's head and
<body>but not the page layout around it (header, listing grid). A style that depends on a page-level class (.brock-page) doesn't apply. - Scripts run (Bootstrap, the theme's script,
ui-components.js), so the screenshots show what a shopper sees once the page has loaded, not a no-JS render. Scripts a page loads outside the head (a listing'sfacet-sheet.js) run only where a case lists them inpatternScripts. Things that open on click stay closed unless the fixture'sframeCssopens them. - The Bootstrap (classic) theme isn't included: its head and fragments are a different generation.