Admin UI components¶
The admin interface has its own design system and no third-party admin theme. Bootstrap 5.3 is
loaded (grid, JS plugins, the markup older screens use) and admin-legacy.css maps its CSS
variables onto our tokens. New and redesigned admin screens use a small component layer of
our own, prefixed bx-, so they do not depend on any Bootstrap version. The older screens are
styled by a compatibility layer (below).
- CSS:
web-mvc/src/main/resources/resources/settBuilder/themes/admin/css/, loaded in this order byadmin/head.htmlandsystem/head.html:admin-legacy.css(older markup),bdgr.css(shell),admin-ui.css(bx-components),admin-ui-ext.css(the dashboard, order and list-page extras: pager, segmented control, dialog, thumbnails). Each carries?v=#{admin.badger.asset.version}(release version plus build timestamp, so every build, including dev deploys that keep the version, busts the year-long cache). - JS:
.../themes/admin/js/admin-ui.js(components, no framework),admin-ui-ext.js(dialogs, row links, tabs, sparklines) andadmin-shell.js(shell and the few page behaviours older screens need), loaded by bothfooterScripts.htmlfiles. - Both
admin-ui-extfiles are loaded on every admin and system page, so a page never has to include them. ThebxFragments :: ext-styles/ext-scriptsfragments older templates call are now empty and can be deleted from a template you touch. They were not merged intoadmin-ui.css/jsbecause the dashboard and order work edits them in parallel; folding is a mechanical concatenation once that settles. - Live reference:
/admin/styleguide(SUPERADMIN) renders every component.
Rules¶
- Use
bx-components for new screens. Don't mix them withibox/panelon the same page region. - Use the
--bx-*tokens, never raw hex values. - Mobile first: every component must work at 360px wide with no horizontal page scroll.
Breakpoint for "desktop" layouts is
min-width: 992px. - Don't use Bootstrap grid classes (
col-*) or Bootstrap JS (data-bs-toggle) insidebx-markup. Use.bx-grid/.bx-stackfor layout. - Business logic and role checks live in Java; templates consume booleans (see CLAUDE.md).
Tokens¶
Defined on :root in admin-ui.css:
| Token | Use |
|---|---|
--bx-brand (#FF8A00), --bx-brand-ink |
Badger amber accent, primary actions |
--bx-ink, --bx-ink-muted, --bx-ink-subtle |
Text |
--bx-surface, --bx-surface-alt, --bx-canvas |
Card, inset and page backgrounds |
--bx-border, --bx-border-strong |
Hairlines |
--bx-success, --bx-warning, --bx-danger, --bx-info (+ -soft tints) |
Status |
--bx-space-1..--bx-space-8 |
4px grid spacing |
--bx-radius-sm/md/lg, --bx-shadow-sm/md |
Shape and depth |
--bx-font |
Inter stack |
Components¶
Page header¶
<div class="bx-page">
<header class="bx-page-header">
<div>
<nav class="bx-crumbs"><a href="/admin/search">Search</a><span>Synonyms</span></nav>
<h1 class="bx-title">Synonyms</h1>
<p class="bx-subtitle">Words shoppers use interchangeably.</p>
</div>
<div class="bx-actions"><a class="bx-btn bx-btn--primary" href="#">Add synonym</a></div>
</header>
...
</div>
.bx-page is the content root for redesigned pages. It sits inside the existing #page-wrapper.
Section sub-navigation¶
Vertical list beside content on desktop; horizontally scrolling chips on mobile.
<div class="bx-split">
<nav class="bx-subnav" aria-label="Settings sections">
<a class="bx-subnav__item is-active" href="?group=checkout">Checkout <span class="bx-count">4</span></a>
<a class="bx-subnav__item" href="?group=basket">Basket</a>
</nav>
<div class="bx-split__main">...</div>
</div>
Cards, tiles, stats, layout¶
.bx-card>.bx-card__header(.bx-card__title,.bx-card__desc, optional.bx-actions) +.bx-card__body+ optional.bx-card__footer..bx-gridauto-fit grid (min column 260px);.bx-grid--stats(min 160px)..bx-stackvertical gap..bx-tileis a whole-card link:<a class="bx-tile" href><i class="bx-tile__icon fa fa-cog"></i><span class="bx-tile__title">Payments</span><span class="bx-tile__desc">…</span></a>..bx-stat:<div class="bx-stat"><span class="bx-stat__label">Searches today</span><span class="bx-stat__value">412</span><span class="bx-stat__meta">…</span></div>.
Settings rows¶
<div class="bx-setting" id="setting-basket-enabled" data-key="basket-enabled">
<div class="bx-setting__text">
<label class="bx-setting__label" for="cfg-basket-enabled">Enable basket</label>
<p class="bx-setting__desc">Help text.</p>
<span class="bx-badge bx-badge--neutral">Default</span> <!-- or bx-badge--info "Customised" -->
</div>
<div class="bx-setting__control">
<label class="bx-switch"><input type="checkbox" role="switch" id="cfg-basket-enabled"><span class="bx-switch__track"></span></label>
</div>
</div>
.bx-input, selects .bx-select, multi-line .bx-textarea.
Save bar¶
<form data-bx-dirty> tracks changes; admin-ui.js shows the page's .bx-savebar (sticky bottom) when any field differs from its initial value and warns before leaving with unsaved changes. bx:dirty-change fires on the form with detail.dirty.
<div class="bx-savebar" hidden>
<span class="bx-savebar__msg"><span data-bx-dirty-count>0</span> unsaved changes</span>
<button type="button" class="bx-btn bx-btn--ghost" data-bx-discard>Discard</button>
<button type="submit" class="bx-btn bx-btn--primary">Save changes</button>
</div>
Feedback and data¶
- Buttons:
.bx-btn+--primary | --secondary | --ghost | --danger, size--sm;aria-busy="true"shows a spinner. - Badges:
.bx-badge+--neutral | --success | --warning | --danger | --info. .bx-callout+--info | --warning | --dangerfor inline notices..bx-empty>.bx-empty__icon,.bx-empty__title,.bx-empty__desc, optional action..bx-tableon a<table>; under 768px each row becomes a card, with cell labels taken fromdata-labelon each<td>..bx-searchwraps an<input type="search">with an icon;.bx-toolbarlays filters/search out above a table.- Toasts: keep using
toastr.
Additions and details¶
These extend the contract above; nothing in it was renamed or removed.
- Field wrapper:
.bx-field>.bx-label, an input,.bx-help,.bx-error. Mark a bad field witharia-invalid="true". - Settings rows are container-aware. A
.bx-settinglays text and control side by side only when its container is at least 640px wide, and stacks otherwise..bx-card__bodyis the container, so put rows inside a card body (or wrap them in.bx-container). This matters because a settings card beside the sub-navigation is much narrower than the screen..bx-setting--stackforces the stacked layout for wide controls such as textareas. - Deep links:
.bx-setting:targetand.is-highlighttint the row.admin-ui.jsaddsis-highlightfor 3.5s when the URL hash names a setting id, so search results can link to/admin/settings/...#setting-basket-enabled. - Dirty rows: a
.bx-settingcontaining a changed field gets.is-dirty(amber rule on the left). - Cards:
.bx-card__body--flushremoves body padding (for a table or list flush to the card edge);.bx-card > .bx-toolbarbecomes the card's filter strip. - Tables:
.bx-table-wrapis an optional horizontal-scroll wrapper; cells can use.bx-table__primary,.bx-table__num(right-aligned, tabular figures) and.bx-table__actions. A cell withoutdata-labelshows no label in card mode. - Layout helpers:
.bx-stack--lg(larger gap),.bx-muted,.bx-subtle,.bx-mono,.bx-sr-only..bx-stat__meta.is-up/.is-downcolour a delta. - Sub-navigation also accepts
aria-current="page"instead of.is-active. The active chip is scrolled into view on small screens.
Save bar and dirty tracking¶
<form data-bx-dirty> baselines every named input/select/textarea (not hidden, button or submit inputs) at DOMContentLoaded, and again on window.load if the user has not started editing, so values set by plugins are not counted as changes.
data-bx-ignoreon a field excludes it;data-bx-trackincludes a hidden or disabled one.[data-bx-dirty-count]inside the save bar shows the number of changed fields;[data-bx-dirty-plural]receivesswhen it is not 1.- The save bar is found by
data-bx-savebar="#selector"on the form, else a.bx-savebarinside the form, else the first on the page. bx:dirty-changebubbles from the form withdetail: { dirty, count }, fired only when the form flips between clean and dirty.- A normal submit sets
aria-busyon the submit button and disables the leave warning. For AJAX saves callBadgerAdminUI.resetDirty(form)after success to re-baseline. data-bx-discardrestores the baseline and firesinput/changeon each reverted field so other listeners update.
window.BadgerAdminUI exposes resetDirty(form), isDirty(form), setBusy(button, bool) and track(form) (for forms added after load).
Shared patterns (fragments, helpers and scripts)¶
Everything below is shared and documented once. Use it instead of rebuilding it per screen. The reference
conversions are admin/collections.html and admin/pages.html (list pages) and
system/globalSettings/configuration.html (a guarded edit page).
Formatting: #bx¶
A Thymeleaf expression object (BxDialect, a bean in web-admin) so every screen formats the same way. Dates and
times use the site's time zone and locale (SiteContext.defaultTimeZone / defaultLocale, else Europe/London,
en-GB). Every method takes null and prints an em dash.
| Expression | Result |
|---|---|
${#bx.date(d)} |
5 Oct 2026 |
${#bx.dateTime(d)} |
5 Oct 2026, 08:55 |
${#bx.relative(d)} |
just now, 12 minutes ago, yesterday, 4 days ago, then the plain date after 30 days |
${#bx.iso(d)} |
ISO instant, for <time datetime> |
${#bx.money(amount, 'GBP')} |
£1,234.50. A Long/Integer is minor units (pence); a BigDecimal is major units |
${#bx.moneyMinor(n, 'GBP')} |
always minor units, whatever the type |
${#bx.number(n)} |
1,234, 12.5 |
${#bx.shortId(id)} |
5f8d0d55… (ids over 10 characters), pair with copyable |
${#bx.plural(n, 'product', 'products')} |
3 products |
${#bx.humanise('partialRefund')} |
Partial refund |
${#bx.status(x)} |
StatusView with label, variant, key |
d may be a Date, Instant, LocalDate, LocalDateTime (read as site time), ZonedDateTime or
OffsetDateTime. Currency is an ISO code; null means GBP. Never print toString() of a date, an ISO string, or
a bare pence amount.
Fragments in admin/bxFragments.html¶
<!-- Chips for list-valued cells. items: strings, or beans/maps with name + seoName/id. max <= 0 or null = all -->
<span th:replace="~{admin/bxFragments :: chips(${product.collectionIDs}, 3, '/admin/catalogue/collection/edit/')}"></span>
<!-- Strings that are seoNames, shown by display name via a names map (seoName -> name) from the controller -->
<span th:replace="~{admin/bxFragments :: chipsNamed(${c.parentCollectionIDs}, ${collectionNames}, 3, '/admin/catalogue/collection/edit/')}"></span>
<!-- hrefPrefix null = plain (unlinked) chips. Beyond max: "+3 more" with the rest in its tooltip. -->
<span th:replace="~{admin/bxFragments :: status(${order.state})}"></span> <!-- badge with dot, human label -->
<span th:replace="~{admin/bxFragments :: status(${p.disabled ? 'disabled' : 'enabled'})}"></span>
<span th:replace="~{admin/bxFragments :: statusAs(${x.state}, 'Needs action')}"></span> <!-- own label, mapped colour -->
<span th:replace="~{admin/bxFragments :: when(${x.lastModifiedDate})}"></span> <!-- "2 days ago", exact time on hover -->
<span th:replace="~{admin/bxFragments :: whenOrDash(${x.lastModifiedDate})}"></span> <!-- the dash when there is no date -->
<button th:replace="~{admin/bxFragments :: copy(${order.number}, 'Copy order number')}"></button>
<span th:replace="~{admin/bxFragments :: copyable(${user.id}, ${#bx.shortId(user.id)}, 'Copy user ID')}"></span>
<div th:replace="~{admin/bxFragments :: empty('inbox', 'No gift cards yet', 'Sell one and it shows up here.', 'New gift card', '/admin/giftcards/new')}"></div>
Resolving seoNames to names: ask the owning service once per page, never per row
(collectionService.findDisplayNames(siteContext, seoNames), pageService.findDisplayNames(siteContext, seoNames)),
put the map in the model and use chipsNamed. Add a sibling service method for another type; do not reach for its
repository (see CLAUDE.md, layering).
Status words understood by #bx.status and the status fragment (case, spaces, _ and - ignored):
| Variant | Statuses |
|---|---|
| success (green) | enabled, visible, listed, published, paid/captured, dispatched, delivered, approved, active, live, ok |
| info (blue) | authorised, packing, requested, trialing, suggested, scheduled |
| warning (amber) | unpaid, refunded, partial refund, refund pending, submitted ("To pack"), received, incomplete, pending |
| danger (red) | failed, payment failed, pending approval ("Awaiting approval"), past due, error |
| neutral (grey) | disabled, hidden, unlisted, draft, cancelled/canceled, rejected, paused, expired; and any unknown word, humanised |
To add a status, edit BxStatus (one line) and the test; do not hard-code badges in templates.
List pages¶
One contract for every admin list: header with the primary action, toolbar (search, filter chips, sort), result
count, a bx-table that becomes cards on phones, a zero state, and a pager. The URL is
?q=&sort=&dir=&page=&size=&filter=, handled by one Java helper, so every list behaves and links the same.
Controller. Define what the list accepts once. A request can only sort by the fields you list and filter by the values you list; everything else is ignored.
static final ListSpec LIST_SPEC = ListSpec.builder()
.sort("name", "name").sort("updated", "lastModifiedDate") // key -> persistence field
.defaultSort("name", Sort.Direction.ASC)
.filters("visible", "hidden")
.build();
@GetMapping("/catalogue/collections")
public String list(HttpServletRequest request, @ModelAttribute SiteContext siteContext, Model model) {
ListQuery query = ListQuery.from(LIST_SPEC, request); // q, sort, dir, page (or legacy p), size, filter
Boolean disabled = query.filter() == null ? null : "hidden".equals(query.filter());
PageDTO<Collection> result = collectionService.findForAdminList(siteContext, query.q(), disabled, query.pageable());
model.addAttribute("collections", result.getContent());
model.addAttribute("list", query.view(result.getTotalElements())); // the model the fragments read
return "admin/collections";
}
ListQuery gives q() (trimmed, at most 100 characters), filter(), pageable() (zero-based page, whitelisted
sort, id tiebreak so paging is stable), qPattern() (a literal, case-insensitive "contains" Pattern for Mongo
Criteria.regex) and view(total). Search must be literal: pass q to a service that quotes it, never build a
regex from it. The service method takes (siteContext, q, filterFlag, Pageable) and derives the catalogue from the
site context. See CollectionServiceImpl.findForAdminList and CustomCollectionRepositoryImpl.searchForAdminList
(case-insensitive collation, so "apple" sorts with "Apple").
Template. Copy admin/collections.html; the pieces are:
<div class="bx-page">
<header class="bx-page-header"> ...crumbs, h1.bx-title, p.bx-subtitle, .bx-actions with the primary button... </header>
<!-- search (Enter, or 400ms after typing), filter chips, sort select. filters: 'value=Label'. sorts: 'key:dir=Label' -->
<th:block th:replace="~{admin/bxList :: toolbar(${list}, 'Search collections', 'Search by name or URL slug',
${ {'visible=Visible','hidden=Hidden'} }, ${ {'name:asc=Name A to Z','updated:desc=Recently updated'} })}" />
<th:block th:replace="~{admin/bxList :: count(${list}, 'collections')}" /> <!-- Showing 1-20 of 210 collections -->
<th:block th:replace="~{admin/bxList :: empty(${list}, 'collections', 'tags', 'New collection', '/admin/...new')}" />
<div class="bx-card bx-card--flat-sm" th:unless="${list.empty}">
<table class="bx-table">
<thead><tr>
<th:block th:replace="~{admin/bxList :: sortHeader(${list}, 'name', 'Collection')}" /> <!-- sortable header -->
<th scope="col">Status</th>
<th scope="col"><span class="bx-sr-only">Actions</span></th>
</tr></thead>
<tbody>
<tr th:each="c : ${collections}" th:data-href="'/admin/.../edit/' + ${c.seoName}"> <!-- whole row opens the record -->
<td data-label="Collection"> <a class="bx-media__title" ...>Name</a> <span class="bx-media__sub bx-mono">slug</span> </td>
<td data-label="Status"> <span th:replace="~{admin/bxFragments :: status(...)}"></span> </td>
<td class="bx-table__actions"><span class="bx-row-actions">...View / Edit buttons...</span></td>
</tr>
</tbody>
</table>
</div>
<th:block th:replace="~{admin/bxList :: pager(${list})}" />
</div>
Rules: every td gets a data-label (the phone card uses it; leave it off for the title and action cells);
numbers use bx-table__num; keep the primary action (Edit) as bx-btn--primary and the rest secondary; Delete is
never a row button (put it on the edit page, in the overflow menu). A zero-row state is always
bxList :: empty (it says "No X match" with Clear when a search or filter is active, else the first-use state with
a call to action).
ListView (the list object) exposes what custom markup needs: q, filter, sort, dir, size, page,
total, totalPages, start, end, empty, narrowed, and link builders pageHref(n), filterHref(v),
sortHref(key), sortHrefFor('key:dir'), clearAllHref, clearSearchHref, carried (hidden inputs for the
search form). Links reset to page 1 on any change except paging, and omit default values.
Edit pages¶
A legacy edit form is retrofitted with attributes and one header fragment; no rewrite needed.
- Unsaved-changes bar. Add
data-bx-dirtyto the<form>. If the page has no.bx-savebar, the script appends one (sticky, Discard and Save). Its Save button clicks the form's own submit button, so a named button and its handlers still run; point at another withdata-bx-submit="#saveBtn"(needed when saving is AJAX), relabel withdata-bx-save-label="Save product", opt out of the bar withdata-bx-savebar="none". Also: Ctrl/Cmd+S saves (a toast says so when nothing changed), the submitting button getsaria-busy(spinner, no double post), and leaving with unsaved changes warns. Everything under "Save bar and dirty tracking" above still applies. - Header. Human title (the name, never the slug), a status badge, "View on storefront", and an overflow menu holding the destructive actions:
<header th:replace="~{admin/bxFragments :: editHeader(
${ {{'Home','/admin'}, {'Collections','/admin/catalogue/collections'}, {c.name, null}} },
${c.name}, ${c.disabled ? 'disabled' : 'enabled'}, ${c.disabled ? null : '/collection/' + c.seoName},
null, ~{::deleteMenu})}"></header>
<th:block th:fragment="deleteMenu">
<a class="bx-menu__item" href="...">Duplicate</a>
<div class="bx-menu__sep"></div>
<form method="post" action="/admin/catalogue/collection/delete"
data-bx-confirm="This removes it from 3 products. This can't be undone." data-bx-confirm-danger
data-bx-confirm-title="Delete Pozi screws?" data-bx-confirm-label="Delete collection">
<input type="hidden" name="id" th:value="${c.id}" />
<button class="bx-menu__item bx-menu__item--danger" type="submit">Delete collection</button>
</form>
</th:block>
Parameters (any may be null): crumbs (list of [label, href], last has null href), title, status (a status word),
viewUrl, actions (a fragment of extra buttons beside the menu), menu (a fragment of .bx-menu__items; null for
no menu). Delete never sits beside Save. <details class="bx-menu"> works standalone: Esc and an outside click close it.
Form building blocks (catalogue edit pages)¶
The product, variant, collection and delivery option editors are built from these, all in admin-ui-ext.css / admin-ui-ext.js under area: catalogue:
- Cards of fields.
section.bx-cardwith abx-card__header(title and one-line description), thenbx-form-grid bx-form-grid--2ofbx-fields (bx-label,bx-input,bx-help,bx-error).bx-affixglues a currency or unit to an input. - Tabs on an edit page.
div.bx-tabs > div.bx-segmented > a.bx-segmented__item[data-bs-toggle=tab]over atab-content bx-tabpanes. Bootstrap's tab plugin toggles.active. - Inverted switch. A switch whose meaning is the opposite of the stored flag ("Show in shop" for
disabled):<input type="checkbox" data-bx-invert="[name=disabled]">beside the real checkbox, hidden. It fireschangeon the real one so the save bar notices. - Counters and preview.
data-bx-counter="60"fills the nearest.bx-counterwith "n / 60";[data-bx-serp]is a live search-result preview. - Rows you add and remove (custom attributes, barcodes) have no value of their own to track. Keep a hidden
<input id="bx-touch" name="bxTouch" data-bx-track>in the form and set its value to mark the form changed. - Discard reloads when the form has rich-text editors:
data-bx-reload-on-discard. A reload is the only reliable reset for Summernote. - Save with a real submit event. A hidden save button's handler must call
form.requestSubmit(), not jQuery's.submit(), or the unsaved-changes tracker never learns the page is saving and warns on the way out. - Editor chrome is ignored. Inputs inside
.note-editorcarrydata-bx-ignoreautomatically, so Summernote's own colour pickers never count as changes.
Confirmation dialogs¶
BadgerAdminUI.confirm({ title: 'Delete Pozi screws?', body: "This can't be undone.",
confirmLabel: 'Delete', danger: true, typeToConfirm: 'screws' }) // optional last key
.then(function (yes) { if (yes) { /* go */ } }); // also available as window.bxConfirm
A native <dialog> styled as bx-dialog; danger makes the confirm button red and puts initial focus on Cancel so
Enter cannot delete; typeToConfirm disables the confirm button until the word is typed (case-insensitive). Esc or a
click outside cancels. Declaratively, on a link, button or form (captured before other handlers, then re-fired once
confirmed, so jQuery and submit handlers still run):
<a href="/admin/x/delete?id=1" data-bx-confirm="Body text" data-bx-confirm-title="Delete this?"
data-bx-confirm-label="Delete" data-bx-confirm-danger data-bx-confirm-type="delete">Delete</a>
<form method="post" data-bx-confirm="Rebuild the whole index? Search is slow while it runs.">...</form>
Write the body as what will happen, including the blast radius ("removes it from 3 products"), not "Are you
sure?". SweetAlert is still loaded and untouched, so existing swal(...) calls keep working; convert them when you
touch them. Where a delete can be undone (soft delete), prefer a toast with Undo to a dialog.
Copy, toasts¶
<button class="bx-copy" data-copy="value" aria-label="Copy SKU"><i class="far fa-copy"></i></button> (or the
copy / copyable fragments) copies data-copy and toasts "Copied" (data-copy-message overrides it); any element
with data-copy works (bx-copy bx-copy--text is a labelled button). BadgerAdminUI.toast(message, 'success'|'error'|'info')
uses toastr when the page has it and a small toast of our own otherwise. BadgerAdminUI.copy(text) returns a promise.
Admin error page¶
An error under /admin/** or /system/** renders templates/error/admin.html (chosen in
BadgerExceptionHandlerController) instead of the shop's theme: status, a friendly sentence for 401/403/404/5xx,
a short log reference, "Back to dashboard" (/system for system pages) and "Copy details". The same reference is in
the ERROR log line, so support can find the failure from a screenshot. It is self-contained (no layout) because the
failure may be in the layout, and the exception is never put in the model. Do not catch exceptions in a controller
just to show a message; let them reach it.
Settings and system pages¶
The entity-backed settings pages (admin/shopSettings/**, admin/upgrades.html, admin/system/breadcrumbs.html)
and every superadmin page (system/**) share one page header fragment, so the breadcrumb always reads
"Settings / Page" or "System / Page" (never "Shop Settings"):
<header th:replace="~{admin/shopSettings/shell :: header(${ {{'Settings','/admin/settings'},{'Theme',null}} },
'Theme', 'One sentence saying what the page is for.', ~{::headerActions})}"></header>
<th:block th:remove="all"><th:block th:fragment="headerActions"> ...buttons... </th:block></th:block>
Avoid an apostrophe inside the quoted title or subtitle: a backslash-escaped quote does not survive the nested
fragment expression. Do not put th:if and th:replace on one element (the replace wins); wrap in th:block.
The superadmin tenant list is a standard list (TenantManagementController.LIST_SPEC, ?q=&sort=&dir=&page=&filter=).
Destructive superadmin actions (clear caches, clear performance statistics, initialise with "trash existing data",
delete a tenant, replace a catalogue by import) ask for a typed word through data-bx-confirm-type or
bxConfirm({typeToConfirm}); delete and import then still ask for the operator's password.
Secrets on admin pages¶
A page must never receive a secret's value: pass a view object that has none (see GlobalConfigRow,
StoredConfigRow) and decide with SettingsViewService.isSecretKey. A blank value from a secret's field means
"leave it as it is". Keys the platform manages itself (jwt*, internal markers) are read-only, deletes need the key
typed back and are enforced server-side, and exports drop secret values. Write a render test that puts a sentinel
secret in the data and asserts it is absent from the HTML (GlobalConfigRenderTest).
The shell¶
The sidebar, top app bar and mobile drawer live in templates/admin/navLeft.html, navTop.html (and the system/ equivalents), styled in css/bdgr.css and driven by BadgerShell in js/admin-shell.js. The shell styles are scoped to #bx-sidebar and .bx-topbar, so legacy ibox/panel pages are unaffected.
- Breakpoint is 992px. Below it the sidebar is an off-canvas drawer (280px, backdrop, Esc / backdrop / link closes it, body scroll locked,
inerton the page while open, focus moved in and restored on close) and the top bar is sticky. From 992px up the sidebar is a fixed 248px rail that the hamburger collapses to icons (the originalmini-navbarbehaviour). - No flash on load. The drawer is closed by a media query in
bdgr.cssalone. It does not depend on JavaScript or on any body class that script adds onwindow.load. State classes:body.bx-nav-open(drawer open),body.mini-navbar(desktop rail collapsed). - Current page.
BadgerShell.markActiveNav()marks the sidebar link with the longest path-prefix match (query string breaks ties, e.g. subscription statuses) and opens its section. The server also renders the section asactiveandin, so the right section is open before JS runs. - Adding a nav item. Add an
<li><a href>to the rightul.nav-second-levelinnavLeft.html; wrap it in the existing role boolean (canManageSettings, ...). Second-level icons are intentionally hidden.li.bx-nav-headingrenders a small caption between groups. Keep#side-menu,.nav-second-leveland.nav-label: the Recently Visited tracker reads them. - Optional entries (Subscriptions, Fundraising, Subscription prices) come from the
navmodel attribute (NavVisibility, built byAdminNavServiceand cached for a minute per tenant): shown when the feature is on, or when the shop holds records for it while it is off (then marked with.bx-nav-hint). Never test the feature flags in the template. - Access booleans (
canManageOrdersand friends) come fromAdminAccess.fromCurrentUser(), the one place the area-to-role mapping lives. The command palette uses the same record. - Profile and sign out are in the top bar menu, not the sidebar. The top bar also carries the palette trigger (
[data-bx-palette-open]) and a "View store" link (storefrontUrl).
Command palette and shortcuts¶
js/admin-palette.js + css/admin-palette.css (included from head.html and footerScripts.html) build the Cmd/Ctrl+K dialog (role="dialog", a combobox input and a listbox of option links, aria-activedescendant). It also owns the shortcuts: ? help sheet, / focus ([data-bx-page-search] or the first visible search input, else the palette), and g then a letter for go-to, enabled only when the sidebar has the target link. Results render with DOM APIs only; hit titles are shop data.
The server side is GET /admin/api/search?q= (AdminSearchController, AdminSearchService). The service takes an AdminAccess and only queries the groups that access allows (orders carry customer details, customers are administrators only), scopes every lookup by SiteContext through services (ProductService.quickSearch, CollectionService.quickSearch, OrderService.quickSearchPlacedOrders, PageService.searchPages, UserService.searchUsersByRole), caps each group, indexes settings by title and key only (never values, cached five minutes per tenant) and skips a group that fails. Sidebar destinations and quick actions live in AdminDestinations; AdminDestinationsTest fails if the sidebar and that catalogue drift apart. Add a destination in both places.
The sidebar's Recently visited list also records record pages (product, collection, order, page, user) by their heading; the palette shows the same list when nothing is typed.
- A .bx-page inside #page-wrapper removes the wrapper's 15px side padding (via :has) so the page controls its own gutters.
Legacy compatibility layer¶
About 190 older admin templates (and the extension admin templates under
web-mvc/.../templates/*/extensions/*/admin/) use Bootstrap 5 plus a vocabulary of
page-chrome classes (ibox, page-heading, wrapper-content, m-t-md, ...) that a third-party
admin theme used to define. That theme is gone. css/admin-legacy.css now styles exactly the classes
those templates use, from scratch, with the --bx-* tokens, so the older screens share the new look
until each is rebuilt natively in bx- components. Nothing new should depend on it.
Section 0 of the file sets Bootstrap's --bs-* variables (fonts, colours, radii, link colours, the
primary/success/danger palette) from the --bx-* tokens, so a stock Bootstrap component that is not
restyled in the file still matches. Buttons are driven by --bs-btn-* variables per variant; the soft
tinted pill for badge text-bg-* replaces Bootstrap's solid fill (those helpers are !important, so the
tints are too).
Counts below are class-attribute and selector occurrences across the admin templates and admin JS
(uses / files), taken when the layer was written, under their Bootstrap 3 names. The codemod has since
renamed label label-* / badge-* to badge text-bg-*, panel* to card*, btn-default to
btn-outline-secondary, btn-xs to btn-sm, input-group-addon to input-group-text, col-xs/sm/md/lg-*
to col-/md-/lg-/xl-* and pull-right to float-end; the layer targets the new names.
| Area | Classes (uses / files) | Handling |
|---|---|---|
| Page frame | wrapper 204/101, wrapper-content 106/99, gray-bg 108/108, white-bg 100/100, border-bottom 113/100, footer 31/31, title-action 2/2 |
Own #page-wrapper (flex column), 24px gutters, footer pinned to the bottom. |
| Page heading | page-heading 98/98, breadcrumb 98/98 |
Header strip: bold title, / separated crumbs, actions drop below the title under 768px. |
| Cards | ibox 285/115, ibox-title 208/86, ibox-content 278/117, ibox-tools 63/49, ibox-footer 1/1, float-e-margins 142/61, panel* 11/6, well 6/4, tabs-container 12/12, blank-panel 2/2 |
Rendered as bx-card. A bare .ibox-content (a filter strip) is its own card. |
| Utilities | m-* / p-* spacing (m-t-md 32/16, m-b-sm 28/19, m-r-xs 25/3, m-t-sm 22/9, m-b-md 17/11, m-t-n-xs 8/4, ...), no-margins 46/10, no-padding 3/3, font-bold 25/10, img-circle 15/5, hr-line-dashed 56/31, display-block |
Same scale as before: xs 5px, sm 10px, md 20px, lg 30px, plain 15px. |
| Text colour | text-muted 328/88, text-success 42/20, text-danger 39/17, text-warning 24/16, text-info 17/10, text-navy 17/12, text-primary 6/6 |
Mapped to --bx-* text colours; text-navy and text-primary use the brand text colour. |
| Labels, badges | label 318/55 with label-primary 60/41, -success 55/27, -warning 61/34, -danger 45/26, -info 42/25; badge 40/10 with badge-info 11/7, -warning 8/4, -primary 7/4, -danger 7/3 |
Soft tinted pills like bx-badge. label-primary means a positive state (Visible, Listed) so it is green; amber is kept for actions and the unread-alerts count. |
| Buttons | btn 522/142, btn-primary 200/130, btn-white 116/51, btn-default 108/57, btn-xs 78/41, btn-danger 68/47, btn-warning/-success/-info, btn-outline 6/4, btn-lg 9/5, btn-w-m 2/1 |
btn-primary is the brand amber; btn-default and btn-white are the secondary button. |
| Forms | form-group 479/87, form-control 423/89, control-label 354/59, has-error 81/19, input-group-btn 36/28, input-group-addon 11/7, file-control 3/1, i-checks 39/4 |
38px inputs with the brand focus ring; select.form-control gets the bx-select chevron; a checkbox that carries .form-control stays box-sized. |
| iCheck | i-checks, icheckbox_square-green, iradio_square-green |
The plugin is kept; its skin is now CSS only (no sprite image). |
| Tables, paging | table 87/67, table-bordered 6/6, pagination 3/3, footable |
Header and cell treatment of bx-table; footable's own stylesheet still draws the expand toggles and sort arrows. |
| Tabs | nav-tabs 17/16, tab-content 15/15, tab-pane 50/16, nav-pills |
Underline tabs; they scroll sideways on phones. |
| Feedback | alert-* (about 120 across 4 variants), modal*, dropdown-menu, progress, list-group, tooltips, popovers |
Callout-style alerts; rounded modals with a tinted footer; menus match the top bar. |
| Widgets | widget / style1 12/3, navy-bg 6/4, lazur-bg 10/8, yellow-bg 4/4, red-bg 2/2, blue-bg 1/1, stat-percent 6/2, mid-icon 2/2, feed-element 8/3, feed-activity-list 3/3, vertical-timeline-* 11/11, dark-timeline 2/2 |
Solid colour blocks, the customer-activity timeline and the feed list are redrawn on the new tokens. |
| Media library | file-box / file-manager / folder-list / tag-list / file-name (media.html only) |
Cards lay out as a grid. |
| Loading | sk-spinner-wave 1/1 (AI menu generator) |
Small CSS bouncing bars. |
| Plugin skins | toastr, summernote, select2, bootstrap-datepicker, sweetalert, jstree, dropzone, footable | Colours and radii aligned in section 14 of the file. |
| Shell | navbar-*, nav-label, nav-second-level, arrow, metismenu, logo-element, count-info, mini-navbar |
Base in admin-legacy.css, look in bdgr.css. |
Behaviours kept in js/admin-shell.js: sidebar accordion (metisMenu, told to use Bootstrap 5's show
class), the hamburger (drawer below 992px, icon rail above), .collapse-link on iboxes (10 templates),
data-bs-toggle="tooltip" and data-bs-toggle="popover" initialisation (Bootstrap 5 never starts these
itself), and moving .modal elements to <body>. Dropped because
nothing uses them: ibox close link, check-link to-do toggles, draggable panels, hover animations,
the forced min-height script, slimscroll, Pace and (outside /system) peity.
The Bootstrap 3 to 5 move¶
The admin moved from Bootstrap 3.4.1 to 5.3.2 with dev-scripts/migrations/bs3to5.py, run once over the admin,
system and extension admin templates, followed by manual fixes. A branch that still has Bootstrap 3 markup
can be converted the same way (python3 dev-scripts/migrations/bs3to5.py --write --report report.md <files>;
run it once per file, breakpoints shift and are not idempotent). What to check afterwards:
- Renamed classes in JS. The report lists
addClass/removeClasson classes that were renamed (btn-defaultis nowbtn-outline-secondary,label-successisbadge text-bg-success,paneliscard). - JS in string concatenation. The class rewriter must not touch a
class="..." + x + "..."string built in JavaScript; check any<script>whose diff drops a+or a quote (node --checkon the extracted script). - Floats. Bootstrap 5's
.rowis a flex container. A child withfloat-end(page-heading actions) is not floated;admin-legacy.cssgives itmargin-left: auto. A<form>wrapped directly in a.rowis made the flex row. Columns outside any.rowstack and lose their gutter padding. - Tabs and dropdowns. Tabs are
li.nav-item > a.nav-linkwith.activeon the link, dropdown togglesdata-bs-toggle="dropdown"; the open menu has.show. Dropdown rows can stayli > a. - Modals. The close button (
btn-close) goes after the title, not before it, since the header is flex. - Scripts before jQuery. A template fragment that renders above
footerScriptsmust not use$at parse time; wait forDOMContentLoaded. - Convert a screen to
bx-components rather than to more Bootstrap classes where it is more than a rename.
Plugins: select2 uses its default theme (skinned in section 14; the select2-bootstrap-theme package is
gone), summernote loads its -bs5 build, bootstrap-datepicker is unchanged, the eonasdan datetimepicker
was unused and is removed, footable 2.0.6 works as it did. The menu editor's own stylesheet bundles a
second Bootstrap, so it is not loaded; its few drag-state rules are in admin-legacy.css.
Migration note: the superadmin site¶
The superadmin site used to be called inspinia-system, after the theme. It is now system
(SystemRenderer.SYSTEM_SITE_ID, templates in web-admin/src/main/resources/templates/system/). Existing
databases are migrated by system update 102 (RenameSystemSite), which renames the siteContext document
and its templatePath. Anything outside this repository that refers to the old site id (support scripts,
dashboards) needs the same change.