Skip to content

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 by admin/head.html and system/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) and admin-shell.js (shell and the few page behaviours older screens need), loaded by both footerScripts.html files.
  • Both admin-ui-ext files are loaded on every admin and system page, so a page never has to include them. The bxFragments :: ext-styles / ext-scripts fragments older templates call are now empty and can be deleted from a template you touch. They were not merged into admin-ui.css/js because 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

  1. Use bx- components for new screens. Don't mix them with ibox/panel on the same page region.
  2. Use the --bx-* tokens, never raw hex values.
  3. Mobile first: every component must work at 360px wide with no horizontal page scroll. Breakpoint for "desktop" layouts is min-width: 992px.
  4. Don't use Bootstrap grid classes (col-*) or Bootstrap JS (data-bs-toggle) inside bx- markup. Use .bx-grid/.bx-stack for layout.
  5. 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

<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-grid auto-fit grid (min column 260px); .bx-grid--stats (min 160px). .bx-stack vertical gap.
  • .bx-tile is 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>
Text sits left and the control sits right on desktop; on mobile they stack. Text inputs use .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 | --danger for inline notices.
  • .bx-empty > .bx-empty__icon, .bx-empty__title, .bx-empty__desc, optional action.
  • .bx-table on a <table>; under 768px each row becomes a card, with cell labels taken from data-label on each <td>.
  • .bx-search wraps an <input type="search"> with an icon; .bx-toolbar lays 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 with aria-invalid="true".
  • Settings rows are container-aware. A .bx-setting lays text and control side by side only when its container is at least 640px wide, and stacks otherwise. .bx-card__body is 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--stack forces the stacked layout for wide controls such as textareas.
  • Deep links: .bx-setting:target and .is-highlight tint the row. admin-ui.js adds is-highlight for 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-setting containing a changed field gets .is-dirty (amber rule on the left).
  • Cards: .bx-card__body--flush removes body padding (for a table or list flush to the card edge); .bx-card > .bx-toolbar becomes the card's filter strip.
  • Tables: .bx-table-wrap is an optional horizontal-scroll wrapper; cells can use .bx-table__primary, .bx-table__num (right-aligned, tabular figures) and .bx-table__actions. A cell without data-label shows 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-down colour 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-ignore on a field excludes it; data-bx-track includes a hidden or disabled one.
  • [data-bx-dirty-count] inside the save bar shows the number of changed fields; [data-bx-dirty-plural] receives s when it is not 1.
  • The save bar is found by data-bx-savebar="#selector" on the form, else a .bx-savebar inside the form, else the first on the page.
  • bx:dirty-change bubbles from the form with detail: { dirty, count }, fired only when the form flips between clean and dirty.
  • A normal submit sets aria-busy on the submit button and disables the leave warning. For AJAX saves call BadgerAdminUI.resetDirty(form) after success to re-baseline.
  • data-bx-discard restores the baseline and fires input/change on 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.

  1. Unsaved-changes bar. Add data-bx-dirty to 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 with data-bx-submit="#saveBtn" (needed when saving is AJAX), relabel with data-bx-save-label="Save product", opt out of the bar with data-bx-savebar="none". Also: Ctrl/Cmd+S saves (a toast says so when nothing changed), the submitting button gets aria-busy (spinner, no double post), and leaving with unsaved changes warns. Everything under "Save bar and dirty tracking" above still applies.
  2. 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-card with a bx-card__header (title and one-line description), then bx-form-grid bx-form-grid--2 of bx-fields (bx-label, bx-input, bx-help, bx-error). bx-affix glues 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 a tab-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 fires change on the real one so the save bar notices.
  • Counters and preview. data-bx-counter="60" fills the nearest .bx-counter with "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-editor carry data-bx-ignore automatically, 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, inert on 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 original mini-navbar behaviour).
  • No flash on load. The drawer is closed by a media query in bdgr.css alone. It does not depend on JavaScript or on any body class that script adds on window.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 as active and in, so the right section is open before JS runs.
  • Adding a nav item. Add an <li><a href> to the right ul.nav-second-level in navLeft.html; wrap it in the existing role boolean (canManageSettings, ...). Second-level icons are intentionally hidden. li.bx-nav-heading renders a small caption between groups. Keep #side-menu, .nav-second-level and .nav-label: the Recently Visited tracker reads them.
  • Optional entries (Subscriptions, Fundraising, Subscription prices) come from the nav model attribute (NavVisibility, built by AdminNavService and 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 (canManageOrders and friends) come from AdminAccess.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/removeClass on classes that were renamed (btn-default is now btn-outline-secondary, label-success is badge text-bg-success, panel is card).
  • 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 --check on the extracted script).
  • Floats. Bootstrap 5's .row is a flex container. A child with float-end (page-heading actions) is not floated; admin-legacy.css gives it margin-left: auto. A <form> wrapped directly in a .row is made the flex row. Columns outside any .row stack and lose their gutter padding.
  • Tabs and dropdowns. Tabs are li.nav-item > a.nav-link with .active on the link, dropdown toggles data-bs-toggle="dropdown"; the open menu has .show. Dropdown rows can stay li > 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 footerScripts must not use $ at parse time; wait for DOMContentLoaded.
  • 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.