Skip to content

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. PatternTemplateResolver is 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, as EmailTemplateResolver does. 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. No SiteContext is 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 own fragment/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 IDialect bean, as Spring Boot's engine does. Messages are messages.properties without any shop's Site Text, in en-GB. Prices are GBP with the symbol style, times Europe/London (a fixture can set shopCurrency, shopCurrencySymbol or shopTimeZone in its model).
  • siteContext is a stand-in map: siteName ("Example Shop"), templateTheme (the theme record, for the head's asset paths), productImagePath, contextProperties. A fixture's site object adds to it. It is never a real SiteContext, 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's mini-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.empty on a CategoryNavModel, order.collectionTag(id) on a DOrder), an object names its type. Only the types in FixtureTypes are 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"; its long-group-no-script copy leaves it out, to show the rail when the script is missing (every value listed).
  • Example data only: invented products and names, example.com addresses. Product images are data: URIs (flat colour squares), so pages need no media.

Adding a pattern

  1. 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.
  2. Write patterns/<id>.json with a case for each state worth seeing: empty, typical, the longest text, the extremes (first and last page, a large count).
  3. 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.
  4. 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 PatternExportTest with PATTERNS_OUT set, writing <theme>/<pattern>/<case>.html, a manifest.json (every page and its widths) and assets/: 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.md and report.html (before, after and a diff image for each changed screenshot) and exits 1 if any screenshot differs by more than UI_SNAPSHOT_THRESHOLD percent 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's header-menus page (patterns/header-menus.json: the theme's whole fragment/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 down aria-expanded on 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-menu or 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:

./dev-scripts/ui-snapshots.sh a11y-baseline target/ui-snapshots/now/pages

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's facet-sheet.js) run only where a case lists them in patternScripts. Things that open on click stay closed unless the fixture's frameCss opens them.
  • The Bootstrap (classic) theme isn't included: its head and fragments are a different generation.