Components¶
How a storefront component is shared across the Nova-family themes instead of copied into each, and which components are shared so far. The admin has its own component layer and contract: Admin UI components. The token tiers these rules build on are in Foundations.
Why¶
Brock, Atelier, Depot, Pop and Spec each carried their own copy of the pager, sort dropdown, category nav, account status, mini basket and facet rail, mostly the same markup with the class prefix, icon file and message-key prefix renamed (Storefront inventory). A fix had to land five or six times, and the copies drifted (the mini basket's clear-basket confirmation exists only in Brock's). A shared component is one copy of the markup, re-skinned per theme through tokens.
The contract¶
Where it lives¶
- A shared component is a fragment in
web-mvc/src/main/resources/templates/nova/ui/<component>.html. Nothing else lives inui/. - Themes never ship a
ui/file of the same name. The theme resolver would let one shadow the shared copy for every caller, which is the fork this contract exists to stop. - The fragment's header comment is the contract: the signature, what each parameter means, what it needs on the model, its public tokens, its message keys and its variants. Keep it current in the same change as the markup.
Naming¶
- Classes are
nova-<component>with BEM elements andis-states:nova-pager,nova-pager__summary,nova-pager__page.is-current. - Styles live in Nova's
bdgr.css, in the ui components section near the end, one sub-section per component headed/* --- ui: <component> (ui/<component>.html) --- */. Every Nova-family theme already loadsbdgr.css, so nothing new is linked. - A component built from classes the theme uses elsewhere too (the search bar, listing, product card
and page shell: the theme's header form, collection page, product page and layout share them)
takes the theme's CSS prefix as a
pfxparameter and keeps those class names, styled in the theme's stylesheet, with anova-<component>hook on its root. Itsbdgr.csssub-section stays empty until those classes are tokenised. - Thymeleaf reads two
__in one attribute as preprocessing, so a BEM class built in an expression must not share an attribute with another one: split it (th:class="${pfx} + '-card__img'" th:classappend="${pfx} + '-card__img--alt'") or resolve the block once inth:with(${now} + '__now').
Tokens¶
- Public tokens are
--<component>-<property>(--pager-item-radius,--pager-current-bg). All are optional. - Every rule in the sub-section reads a public token with a fallback:
border-radius: var(--pager-item-radius, var(--radius-md)). The fallback is a semantic token where one fits, otherwise the value of the design the component replaced. - When one value feeds several rules, resolve it once on the component root into
--<component>c-<property>and read that (--pagerc-item-font, used by both page numbers and Previous / Next). This is howcommerceTokens.htmlresolves--nb-*into--nbc-*. - The rules are written only against the component's own tokens and semantic tokens. They never
read a theme's private tokens (
--d-ink), which don't exist in other themes. - A theme re-skins the component by setting its tokens on
:rootor on the component's root class (every pager theme uses.nova-pager { --pager-…: … }), never by restyling the component's selectors.
Parameters, not forks¶
What genuinely differs between themes is passed in:
| Parameter | What | Example |
|---|---|---|
| Icon set | The theme's icon fragment file, which must declare the named icon fragments the component calls | 'fragment/depot-icons' (declares chevronLeft(cls), chevronRight(cls)) |
| Message-key prefix | The theme's key prefix; keys are <prefix>.<family>.* |
'depot' reads depot.pager.summary |
- A null prefix means the shared
ui.<family>.*keys (ui.pager.summary). Add those tomessages.propertieswith the component. - Passing the theme's prefix keeps merchants' existing Site Text overrides (of
depot.pager.summary, say) working with no data change. Don't rename a theme's keys to share a component. - Data comes from the same model the theme's fragment used (
PaginationParamsfor the pager). A shared component adds no model attributes and calls no beans, like every storefront template.
Theme delegates¶
Nova's templates call fragment/pagination, so each theme's old fragment file stays as a
one-line delegate: same file name, same fragment name and signature, no markup.
<th:block th:fragment="pagination(pagParams)"
th:insert="~{ui/pager :: pager(${pagParams}, 'fragment/depot-icons', 'depot')}"></th:block>
The delegate is still needed because it shadows Nova's own fragment/pagination.html, which is a
different design. Its header comment says which icons and keys it passes and where its tokens are
set. A theme layered on a theme with a delegate (Bistro on Brock) inherits it and needs no file.
No visual change¶
Sharing a component must not change how any theme looks. Compare screenshots of every theme
that used the old copy, before and after, at phone and desktop widths; once the pattern library
(/system/patterns) exists, take them from its fixtures. Any difference is a bug unless the merge
request lists it. Behaviour that had drifted between copies (a missing guard, a missing
confirmation) is fixed in the same change only when the MR says so.
Adding a shared component¶
The pager is the worked example (ui/pager.html, the "ui: pager" sub-section of bdgr.css).
- Pick one from the inventory. Take a component whose copies are a rename of each other (a residual of a few lines in Storefront inventory). The pager's five copies differed by one comment line.
- Write
ui/<component>.htmlfrom the baseline copy (Brock's, for the Brock family). Rename classes tonova-<component>__*, replace the theme's key prefix with thekeysparameter (#{__${k}__.pager.summary(...)}, withk = keys ?: 'ui') and the icon file with theiconsparameter (~{__${icons}__ :: chevronLeft(null)}). Write the header comment. - Add the shared keys (
ui.pager.summary,.label,.previous,.next,.page) tomessages.propertieswith the baseline's English. Keep screen-reader text (aria-label) on keys too (Storefront text). - Write the CSS sub-section in
bdgr.css, every property throughvar(--<component>-<property>, fallback), with fallbacks that reproduce the baseline's look. - Move each theme's values into tokens. Turn the theme's old rules for the component into one
token block (
.nova-pager { --pager-item-border: 1.5px solid var(--b-line); … }) and delete the old selectors. Depot's block also switches the layout (--pager-display: flex,--pager-justify: space-between), which is why layout properties are tokens too. - Replace each theme's fragment with a delegate that passes its icons and key prefix.
- Check for no visual change in every affected theme, and update the catalogue below.
- Update the guard tests' lists: remove the theme files that became delegates from
theme-forks.txt, add a pattern fixture if none renders the component, and lower any baseline the ratchet reports.
Guard tests¶
Plain JUnit tests in web-mvc (no Spring context) that keep the contract from eroding. Their lists
live in one place each, so a change that shares a component updates them in the same MR.
| Test | What it catches |
|---|---|
ThemeForksTest |
A Nova-family theme template that shadows a file of the same path lower in its chain (Nova, Bootstrap, or Brock for Bistro) without being a delegate to ui/, unless web-mvc/src/test/resources/theme-forks.txt lists it with a reason (design, planned, hook, mixed). Also an entry there whose file became a delegate, stopped shadowing or was deleted, so the list only shrinks; and any theme shipping a ui/ folder |
UiComponentContractTest |
A ui/ file without the copyright or a header comment documenting each fragment signature; a message key a component reads through its prefix (#{__${k}__.pager.next}) missing from messages.properties, for the ui.* default or for any prefix a theme delegate passes; a bdgr.css ui: <name> (ui/<file>.html) sub-section naming no file, a component with no sub-section, or rules in a sub-section reserved for a component not built yet (PLANNED_CSS_SECTIONS) |
ThemeTokenValuesTest |
A theme (stylesheet or template <style>) setting a component token to inherit, initial, unset or revert, which acts on the custom property and lets the component default win; and a token with a component prefix that no component reads, such as a typo (--pager-item-sise). The prefixes and tokens are read from the ui components section of bdgr.css |
StorefrontStyleRatchetTest |
Per theme, more style="" / th:style attributes or raw hex colours in templates, or raw hex in a stylesheet outside :root custom properties, than storefront-style-baseline.properties allows. A lower count passes and prints the figure to lower the baseline to. ui/ is held to zero (the facet rail's data-driven swatch colour is the one listed exception) |
PatternCoverageTest |
A ui/ component that no pattern library fixture renders (for some theme, the fixture's fragment resolves to a file that calls the component and the output contains its root class), so every shared component has screenshots |
Renames and custom CSS¶
Sharing a component renames the themes' classes (brock-footer__base became
nova-sitefooter__base, the *-btn families became nova-button), and tenants' stored custom
CSS ($cssOverride$/global.css, see Site customisation)
was written against the old names. Three things keep it working:
- The map.
commerce-core/src/main/resources/design/class-renames.jsonlists every class, id and data attribute the shared components removed from storefront markup, old name to new name, grouped by the theme that used it. It was derived by rendering every pattern fixture with the templates from before and after and pairing each element's lost classes with its gained ones, then checking each pair against the templates. A value that is a list means one old name now reads as several (brock-facet__countis the facet rail'snova-facetrail__countand the category navigation'snova-cat-nav__count); a list that keeps the old name means the class is still used elsewhere (brock-stepperin the full basket).ClassRenamesTestfails when a renamed name comes back into a template or theme script, or a new name disappears. - The rewrite.
CssClassRenamerrenames names in selectors only (rule preludes, nested rules,@media/@supports/@layerblocks), never in declarations, strings,url()s, comments or@keyframes, whole identifiers only, and is idempotent. TheRenameClassesInCustomCsssystem update ran it over every site's stylesheet and bumpedcss-override-version; MCPmanageBranding setCustomCssruns it on save and lists what it renamed incustomCss.notes. The admin CSS editor saves what it's given. - Inline styles. JSON components style through
nova-jc-*classes now, so a selector on the style attribute (.json-container[style*="shadow"]) can't be rewritten. The site settingjson-component-inline-stylesmakes the resolver write the old inline styles again beside the classes (SemanticTokenResolverInlineStylesTestchecks they equal the old output); the update turned it on for sites whose CSS has such selectors.
When a change renames storefront classes, add the pairs to class-renames.json and ship a new
dated system update that runs RenameClassesInCustomCss again (it only touches stylesheets that
use a mapped name). Hero, scene and email-capture inline styles moved to classes and tokens too;
their class names didn't change, but a selector on their old style attribute no longer matches
and nothing restores it.
Catalogue¶
Every storefront component family from the Storefront inventory, with its status. This table is updated in the same change that shares a component (or decides a family stays with the themes).
| Component | Status | Why |
|---|---|---|
Pager (fragment/pagination) |
shared | ui/pager.html; Brock, Atelier, Depot, Pop and Spec delegate to it, Bistro inherits Brock's. Nova's own pagination is a different design and stays Nova's |
Sort dropdown (fragment/sort-dropdown) |
shared | ui/sort.html (classes nova-sort, tokens --sort-*); Brock, Atelier, Depot, Pop and Spec delegate to it, passing their icons, <prefix>.plp keys and Apply button classes; the inline sort forms on their collection pages use the same classes. Nova's own is a different design |
Category nav (fragment/category-nav) |
shared | ui/category-nav.html (classes nova-cat-nav, tokens --catnav-*); Brock, Depot, Pop and Spec delegate to it, and the Depot and Spec collection pages draw the same tree by hand with the same classes. Atelier stays a fork: it draws the data as a row of chips, a different structure. Nova's own is a different design |
Account status (fragment/account-status) |
shared | ui/account-status.html; Brock, Depot, Pop and Spec delegate to it (Bistro inherits Brock's). It is one of the header's action buttons, so the markup is shared but the look stays in each theme's *-action rules (passed as the action parameter), not in tokens. Atelier stays a fork: a plain text link. Nova's own is a different design |
Mini basket (fragment/mini-basket) |
shared | ui/mini-basket.html (the drawer: panel and content); Brock, Atelier (its text variant), Depot, Pop and Spec delegate to it, Bistro inherits Brock's. Every theme now has Brock's clear-basket question. The header link stays each theme's (a nav action button). Its script is NovaUi.miniBasket in nova/js/ui-components.js. Brock's added-basket pop-over is not part of it (each theme still has its own copy of that script) |
Facet rail (fragment/facet-rail) |
shared | ui/facet-rail.html (block nova-facetrail, since .nova-facet-rail is Nova's listing column); Brock, Depot, Pop and Spec delegate to it, Bistro inherits Brock's. Pop's pills are tokens (--facet-rail-box-display: none and the value tokens). Atelier keeps its fork: its groups are always-open <section> columns in a drawer, not <details>. Nova's own rail is a different design and stays Nova's. A long group folds to six values only once facet-sheet.js has marked it data-facet-folded (Atelier's fork too), so without that script every value is listed |
Footer (fragment/footer) |
shared | ui/footer.html, classes .nova-sitefooter* (Nova's own footer already owns .nova-footer*); Brock, Depot, Pop and Spec delegate with their icons, key prefix and page-width class, Bistro inherits Brock's and still fills the fragment/footerNotice hook (Nova now ships it empty so the other themes resolve it). The payments row is hidden when commerceChromeEnabled is false for all four (Depot, Pop and Spec used to show it). Atelier, Ridge and Diorama keep their own footers: different designs |
Auth head (fragment/auth-head) |
shared | ui/auth-head.html, the head of the standalone account pages (sign in, password and messaging pages). Its rules are page-level (body, .btn, .form-control), so they stay in the fragment's own <style> after the theme's stylesheet rather than in bdgr.css, and read --auth-* tokens set on :root in each theme's stylesheet. Variant nova (Nova, Pop, Ridge: Nova's file passes its blue palette) or brock (Brock, Bistro, Depot, Atelier, Spec, Diorama: bordered card, taller fields). Each theme's file is a delegate passing its stylesheet; Atelier, Spec and Diorama also pass fragment/<theme>-auth (their fonts, and the few rules the shared ones don't set) |
Language menu (ui/language-menu) |
shared | The header's flag menu and the head's hreflang links (languageSites), once Nova's own; every theme's nav and head call it, passing the header keys, and set --lang-* tokens on .nova-lang |
| Menu, collection chooser, search bar extensions | shared | ui/menu.html, ui/collection-chooser.html, ui/search-bar.html; Brock, Atelier, Depot, Pop and Spec delegate through their extensions/… files (Nova ships its own chooser and search bar, so the delegates shadow those; no theme-less Menu Generator template exists). The markup is shared and the menu classes are nova-menu__*; the CSS is not yet tokenised (it is entangled with each theme's header and drawer), so each theme keeps its .nova-menu__* and .<prefix>-search* rules. The JS is shared: nova/js/ui-components.js (NovaUi.menus, the Menu Generator cache in NovaUi.restoreMenu, and the phone drawer in NovaUi.drawer), loaded by each of the five heads before the theme script, which passes its own selectors, timings and page lock. Ridge (.rg-menu) and Diorama (<details>) have their own menu markup and script. The search bar keeps the theme prefix because the theme's header fallback form uses the same classes |
Product rails (*-rail: featured, trending and recently viewed) |
shared | ui/rail.html; Brock, Atelier, Depot, Pop and Spec delegate through their extensions/featuredProduct/main, trendingProducts and recentlyViewedProducts files (shadowing Nova's own designs), Bistro inherits Brock's. The card is a parameter (default productFragment/railCard, resolved per theme); Atelier uses the stagger variant |
Hero extension (nova/extensions/hero/*) |
shared (tokens) | One Nova template per preset that no theme forks, so it stays in extensions/ rather than ui/ (its photo and colours are per-hero custom properties on the section, --hero-bg-image, --hero-bg, --hero-text, which ui/'s zero-inline-style rule doesn't allow). Classes nova-hero*, styled in bdgr.css "ui: hero" against --hero-* tokens, listed in extensions/hero/main.html's header. Ridge sets --hero-accent (gradient preset) and the stats tokens; pattern hero |
Animation scene (nova/extensions/animationScene/scene) |
shared (tokens) | The container is .bdgr-scene.nova-scene, styled in bdgr.css "ui: scene"; the spec's canvas block arrives as --scene-height, --scene-width, --scene-background, --scene-padding (built by AnimationSceneExtension). The scene's own elements are styled by the spec and animation-runtime.css; pattern animation-scene |
Email capture dock (nova/extensions/emailCaptureExtension/popup) |
shared (tokens) | Classes nova-capture*, styled in bdgr.css "ui: capture" against --capture-* tokens, listed in the template's header. Spec and Diorama set --capture-bg to their dark surface (their [style*="background: white"] rule used to do it); pattern email-capture |
Listing body (fragment/<theme>-listing) |
shared | ui/listing.html (hook nova-listing); Brock, Depot, Pop and Spec delegate to it (Bistro inherits Brock's), Spec with the split variant (the sort in a toolbar group) and Depot with views (its grid / list switch and list captions, which its collection page also uses). The markup is shared but not the CSS: the toolbar, chips, filter panel and grid are the theme's own classes, shared with its collection page, so they keep the theme's prefix (pfx) and styles. Atelier stays a fork: a different layout |
Listing and rail card (listingPageProduct, railCard) |
shared | ui/product-card.html (card and rail, hook nova-productcard); Brock, Pop (tone), Depot (trade) and Spec (trade monogram) delegate to it, Bistro inherits Brock's. Built from the theme's atoms (price, stock, stepper, badge, button), which its product page and basket share, so the classes keep the theme's prefix and the look stays in its stylesheet. Every variant books an appointment type sold as the product: a full-width Book in the variant's primary button (nova-button --buy on Pop, Depot and Spec; the trade card drops its stepper and stock line) and Free for a zero price in its own price atom, on the rail card too. Atelier keeps its own cards: a different layout |
Buttons (*-btn) |
shared | ui/button.html, classes nova-button with --buy --ink --outline --quiet --text --ghost --light, --sm --lg --block, tokens --button-* (a variant token falls back to the base token, then the default, Brock's look). Brock, Bistro, Depot, Pop, Spec, Atelier, Ridge and Diorama set tokens on .nova-button instead of their own families (Bistro's header pills on .bi-header .nova-button); dev-scripts/migrations/ui-buttons.py renamed the classes. Nova's nova-btn (Nova's templates, JSON components, tenant content) keeps its classes and its place in bdgr.css but reads the same tokens, set on .nova-btn. The booking (tb-btn), basket handover (nb-ho-btn, on the --nb-* tokens) and login error (le-btn, a standalone page) buttons stay their pages' own |
Page shells (product, searchResults, merchandisingNode, page) |
shared | ui/page-shell.html (product, page, search, category; hook nova-shell on <body>); Brock, Depot, Pop and Spec delegate all four, Atelier the product and content pages, Bistro inherits Brock's. The page classes keep the theme's prefix. The category page reads the theme's <keys>.plp.searchScope|searchAll|searchIn when it has them (Depot) and the shared search.inCategory.* otherwise. Atelier's search and category pages stay forks: an editorial layout |
| Product extensions (featured, trending, recently viewed) | planned | Eight or nine lines each, rename only |
Nav (fragment/nav) |
theme-owned | A different design in every theme. They share the language menu (ui/language-menu), and every one hides its account and basket controls, bar and drawer, when commerceChromeEnabled is false (absent means a shop); StorefrontChromeRenderTest checks both per theme |
Head (fragment/head) |
theme-owned | Fonts, stylesheets and scripts are per theme; the shared parts are kept in step by review. The hreflang links come from ui/language-menu :: alternates |
Collection page (collection.html) |
theme-owned | Layouts differ (Spec's showcase, Depot's list view) |
Product detail (productFragment/productDetail) |
theme-owned | Layouts differ by 50 to 300 lines |
Icons (*-icons) |
theme-owned | The drawing is the theme's; shared components take the file as a parameter |
JSON components (extensions/jsonComponent/main, SemanticTokenResolver) |
shared | One Nova template for every Nova-family theme (no theme forks it). The resolver turns style tokens into classes, nova-jc-<family>-<token> plus Nova's own (nova-btn-*, nova-bg-*, nova-reveal), styled in bdgr.css's "JSON components: semantic tokens" section (not a ui: section: the template isn't in ui/) through public --json-* tokens. The rules are :where(.class) { … !important }, as strong as the inline styles they replaced; JsonComponentTokenCssTest keeps classes and rules in step. A primary button on a success, error, warning, primary or primary-dark section takes --json-bg-<token>-button-{bg,color}, resolved on the section (default: the section's text colour, labelled in the section's colour; the on-dark accent on primary and primary-dark), so it clears WCAG AA (3:1 shape, 4.5:1 label) in every theme. On those five backgrounds all text inside reads in --json-bg-<token>-text (headings, body, overlines, and descendants given the muted, secondary or primary colour), set per theme so it clears 4.5:1 (ink on Nova's success and error and on Depot's, Spec's and Diorama's light primary). Any other background inside the section drops both. A colour token on the section itself still wins over its background, as the later key did inline. Pattern json-component. The template's own markup still carries inline base styles; the Bootstrap theme keeps its inline mapping |
| Blog templates (Ridge, Diorama) | theme-owned | Content-site designs that differ from Nova's and from each other |
Nova fragments that no theme forks (breadcrumb, responsive-image, search-form,
quick-filters, icons and the rest listed in the inventory) are already single copies. They
move into ui/ only when a theme needs to re-skin one. The basket and checkout are also single
copies, re-skinned through the --nb-* tokens in fragment/commerceTokens.html.