Light API 0.4.0 is a public beta: a minor release such as 0.5.0 may still break. What that means for your module
Light

Design system

Page layout, components, JS modules, forms and lists for your own Light pages.

This is the contract every feature module (Mrx_Home, _Orders, _Catalog, _Ai, _Customers, _Content, _Discounts, _Settings) builds on. Read it together with the design spec (docs/superpowers/specs/2026-09-22-magerex-light-admin-design.md). Everything below exists and was checked in the browser; the living reference is the UI kit page at light/kit/index (open it with openAdmin(page, 'light/kit/index') in a test). The kit, its bulk-action endpoints and light/url/resolve only answer in developer mode; in production they are 404.

Multi-channel and multi-language shops: section 16 has the channel contracts (channel model, table channel filter and column, language switcher, Channels card, "Applies to" for settings, View store menu (the owner can switch it off), palette badges).

Bridges, apps and third-party modules: the extension API is the developer guide: the rules, the recipes, the generated reference and a worked example. Section 17 points into it.

Rule of thumb: a feature module only adds files inside its own module. Nothing in Mrx_Light needs to change to add a screen, a nav badge, a redirect, a table or a search group: all of those are registered through your module's etc/adminhtml/di.xml and layout files.


1. What already exists in your module

Each feature module has registration.php, etc/module.xml (sequence on Mrx_Light plus the Magento modules it extends), an empty i18n/nl_NL.csv, and etc/adminhtml/routes.xml:

<router id="admin">
    <route id="light">
        <module name="Mrx_Orders" before="Mrx_Light"/>
    </route>
</router>

So a controller class Mrx\Orders\Controller\Adminhtml\Orders\View answers light/orders/view, and its layout handle is light_orders_view (route id light + controller path + action). All modules are enabled in app/etc/config.php.

Nav targets already wired: light/home/index, light/orders/index, light/products/index, light/categories/index, light/customers/index, light/pages/index, light/blocks/index, light/menu/index, light/discounts/index, light/settings/index. Until a target's controller exists, the nav item, its "Go to" entry, its g x shortcut and (for Home) the top-bar logo use the item's fallback_route, the matching stock screen (section 8), so simple mode never links to a 404.


2. A new Light page

Controller

Extend Mrx\Light\Controller\Adminhtml\AbstractPage. It is a GET action (HttpGetActionInterface) that creates the page result, adds layout handle mrx_page (Light page header, no stock title bar or button bar, mrx-page body class, device-width viewport), prepends the browser title, sets the stock menu item for advanced mode and the page width.

<?php

declare(strict_types=1);

namespace Mrx\Orders\Controller\Adminhtml\Orders;

use Magento\Framework\Phrase;
use Mrx\Light\Controller\Adminhtml\AbstractPage;

class Index extends AbstractPage
{
    public const ADMIN_RESOURCE = 'Magento_Sales::sales_order';

    protected const ACTIVE_MENU = 'Magento_Sales::sales_order';

    protected const PAGE_WIDTH = 'full';

    protected function getPageTitle(): Phrase|string
    {
        return __('Orders');
    }
}
MemberMeaning
#[RequiresModule('Vendor_Module')]Optional class attribute (Mrx\Light\Attribute\RequiresModule) for a page that only works while another module is enabled. The page then answers 404 while that module is off, and nav items, redirects and palette entries pointing at its route treat it as missing (section 17.2). It works the same on a JSON or POST controller extending Magento\Backend\App\Action.
ADMIN_RESOURCEACL resource (spec D6). Required. A page that keeps the inherited Mrx_Light::light is refused for everyone, because roles saved before MageRex existed inherit every Mrx_Light::* resource from Magento_Backend::admin.
ACTIVE_MENUStock menu id highlighted in advanced mode. Optional.
PAGE_WIDTHself::WIDTH_DEFAULT (998px), self::WIDTH_NARROW (662px, settings forms) or self::WIDTH_FULL (index pages that need room); the plain strings default/narrow/full still work. Adds body class mrx-page--narrow / mrx-page--full.
getPageTitle()Browser title and default page header title. In simple mode the browser tab reads {page title} · {store name} (plugin Mrx\Light\Plugin\SimpleModeTitle on Magento\Framework\View\Page\Title::get(), for Light and stock pages alike); the stock menu path that setActiveMenu() prepends is dropped.
execute(): ResultInterfaceDefault: return $this->createPage($this->getPageTitle());. Override for pages that load data.
createPage(Phrase|string $title): PageBuilds the page as above.
getPageHeader(Page $page): ?PageHeaderThe header block. Calling it builds the layout, so add any extra handles ($page->addHandle(...)) before you call it.

A detail page that loads an entity and configures the header from PHP:

public function __construct(
    Context $context,
    PageFactory $resultPageFactory,
    private readonly OrderRepositoryInterface $orderRepository
) {
    parent::__construct($context, $resultPageFactory);
}

public function execute(): ResultInterface
{
    try {
        $order = $this->orderRepository->get((int)$this->getRequest()->getParam('id'));
    } catch (NoSuchEntityException) {
        $this->messageManager->addErrorMessage(__('This order no longer exists.'));
        return $this->resultRedirectFactory->create()->setPath('light/orders/index');
    }

    $page = $this->createPage(__('Order #%1', $order->getIncrementId()));
    $this->getPageHeader($page)
        ?->setBack($this->getUrl('light/orders/index'), (string)__('Orders'))
        ->addBadge((string)__('Paid'), 'success')
        ->addBadge((string)__('Not shipped'), 'attention');

    return $page;
}

Blocks get their data from a ViewModel that reads the request (RequestInterface::getParam('id')) and the repository; do not use the registry.

Layout

view/adminhtml/layout/light_orders_view.xml (handle = full action name):

<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceContainer name="content">
            <block class="Magento\Backend\Block\Template" name="mrx.orders.view" template="Mrx_Orders::order/view.phtml">
                <arguments>
                    <argument name="view_model" xsi:type="object">Mrx\Orders\ViewModel\OrderView</argument>
                    <argument name="icons" xsi:type="object">Mrx\Light\ViewModel\Icons</argument>
                </arguments>
            </block>
        </referenceContainer>
    </body>
</page>

Put page content in container content. Server messages ($this->messageManager) still render above it; prefer toasts for success feedback.

Template skeleton

<?php
/**
 * @var \Magento\Backend\Block\Template $block
 * @var \Magento\Framework\Escaper $escaper
 * @var \Mrx\Orders\ViewModel\OrderView $viewModel
 * @var \Mrx\Light\ViewModel\Icons $icons
 */
$viewModel = $block->getData('view_model');
$icons = $block->getData('icons');
?>
<div class="mrx-layout" data-mage-init='{"Mrx_Orders/js/order-view": {}}'>
    <div class="mrx-layout__main">
        <section class="mrx-card">...</section>
    </div>
    <aside class="mrx-layout__aside">
        <section class="mrx-card">...</section>
    </aside>
</div>

Escape everything: $escaper->escapeHtml(), escapeHtmlAttr(), escapeUrl(), escapeJs(). JSON for data-mage-init / x-magento-init goes through escapeHtmlAttr() (attribute) or Magento\Framework\Serialize\Serializer\JsonHexTag (script body). Only /* @noEscape */ for $icons->render() output and child block HTML.


3. Page header (mrx.page.header)

Class Mrx\Light\Block\PageHeader, added to main.top by handle mrx_page. Title defaults to the page title.

Layout XML arguments (static headers):

<referenceBlock name="mrx.page.header">
    <arguments>
        <argument name="subtitle" xsi:type="string" translate="true">All orders from every channel</argument>
        <argument name="back_route" xsi:type="string">light/orders/index</argument>
        <argument name="back_label" xsi:type="string" translate="true">Orders</argument>
        <argument name="badges" xsi:type="array">
            <item name="beta" xsi:type="array">
                <item name="label" xsi:type="string" translate="true">Beta</item>
                <item name="tone" xsi:type="string">info</item>
            </item>
        </argument>
        <argument name="primary_action" xsi:type="array">
            <item name="label" xsi:type="string" translate="true">Create order</item>
            <item name="route" xsi:type="string">sales/order_create/start</item>
            <item name="resource" xsi:type="string">Magento_Sales::create</item>
        </argument>
        <argument name="secondary_actions" xsi:type="array">
            <item name="export" xsi:type="array">
                <item name="label" xsi:type="string" translate="true">Export</item>
                <item name="icon" xsi:type="string">external</item>
                <item name="id" xsi:type="string">orders-export</item>
            </item>
        </argument>
        <argument name="more_actions" xsi:type="array">
            <item name="advanced" xsi:type="array">
                <item name="label" xsi:type="string" translate="true">Open in advanced view</item>
                <item name="route" xsi:type="string">sales/order/index</item>
                <item name="params" xsi:type="array">
                    <item name="_query" xsi:type="array">
                        <item name="mrx_stock" xsi:type="string">1</item>
                    </item>
                </item>
            </item>
        </argument>
    </arguments>
</referenceBlock>

PHP setters (fluent, return the block): setTitle(string), setSubtitle(string), setBack(string $url, string $label = ''), addBadge(string $label, string $tone = 'neutral', string $progress = '', string $progressTone = '') ($progress is incomplete|partial|complete and draws the same progress icon as a table badge, section 4; $progressTone success makes that icon green, as on Paid and Shipped; layout badges items take progress and progress_tone keys too), setPrimaryAction(array), addSecondaryAction(array), addMoreAction(array).

Action array keys: label (required), url or route + params, id, tone (critical), icon (icon name), disabled (bool), target (_blank), type (button/submit), form (form id, so a header Save can submit a form), resource (ACL; hidden when not allowed), attributes (extra HTML attributes, e.g. data-mrx-contact), sort_order. With a URL the action renders as <a>, otherwise as <button>; bind your JS to its id. The href goes through escapeUrl(), which strips javascript:/data: URLs. Secondary actions render as secondary buttons, more actions in the "More actions" popover, the primary action last.

On phones (below 768px) the header folds: Mrx_Light/js/page-header (installed by the shell) moves every secondary action into the first section of "More actions", so "More actions" and the primary action share the first row. The element itself moves, so its id, its listeners and anything your JS changes later (label, hidden, disabled) go with it; while folded it carries mrx-popover__item and role="menuitem" instead of mrx-btn mrx-btn--secondary, and it moves back when the screen widens. Bind to the action's id or delegate from document; don't select header actions by .mrx-btn. A header that your page swaps for freshly rendered markup (order and return pages do after an action) folds again by itself. A header with secondary actions but no more actions renders the "More actions" button for phones only (mrx-page-header__more--fold-only). Three exceptions keep the menu honest: an icon-only secondary (mrx-btn--icon in its class, such as the order pager) stays on the row so paging stays in reach, and carries no data-mrx-header-secondary; a secondary link the top bar already offers (Home's "View store", when the top bar shows one) is hidden on phones instead of repeated in the menu; and a folded action that is hidden does not count, so the fold section (and a phone-only "More actions") disappears when nothing visible is left in it, also when your JS toggles hidden later. An actions row with nothing visible left collapses. On phones the menu hangs from the actions row, not from its button, so it never runs off the screen when the pager sits before the button; it is as wide as the row at most and long items wrap. Items without an icon line up with the text of the items that have one. The breakpoint is max-width: 767.98px in both light.css and the script, so a zoomed width between 767 and 768px gets one layout, not half of each.

Actions from other modules. A module adds an action to the More actions menu of any Light page through the Mrx\Light\Model\PageHeader\ActionPool argument actions (catalogue pool header_actions), keyed by the page's full action name and then the action key, with label, route, params, resource, icon, advanced, condition (a Mrx\Light\Api\PageHeader\ActionConditionInterface), module and sort_order. These always land in More actions, never next to the primary action. The UI kit registers light_kit_index ⇒ kit_all_settings as the example.


4. Components (CSS)

Mrx_Light/css/light.css loads on every admin page after the theme styles (verified in <head>: css/styles.css then Mrx_Light/css/light.css). Use only mrx-* classes on Light screens; never style by nth-child or with content: text; don't write new CSS for things listed here. Your module may ship its own small CSS file for screen-specific layout (add it in your page handle's <head><css src="Mrx_Orders::css/orders.css"/></head>), using the tokens below.

Tokens (:root)

--mrx-font, --mrx-font-mono; sizes --mrx-font-size-xs|sm|md|lg|xl|2xl|3xl (11/12/13/14/16/20/24px); line heights --mrx-line-height-sm|md|lg; weights --mrx-weight-regular|medium|semibold|bold. Colours: --mrx-color-bg (#f1f1f1), --mrx-color-surface, -surface-secondary, -surface-tertiary, -surface-hover, -surface-active, -surface-selected, -surface-inverse, --mrx-color-border, -border-secondary, -border-input, -border-focus, --mrx-color-text, -text-secondary, -text-disabled, --mrx-color-link, --mrx-color-primary (#303030), --mrx-color-critical, --mrx-color-text-critical, --mrx-color-text-success. Tones: --mrx-tone-{success|info|warning|critical|attention|neutral}-{bg|text|strong} and --mrx-tone-{success|info|warning|critical}-surface. Spacing: --mrx-space-0-5|1|1-5|2|3|4|5|6|8|10|12|16 (2…64px). Radius: --mrx-radius-sm|md|lg|full (6/8/12/999). Shadows: --mrx-shadow-card, --mrx-shadow-bevel, --mrx-shadow-popover, --mrx-shadow-modal. Layout: --mrx-topbar-height (56), --mrx-nav-width (240), --mrx-page-width (998), --mrx-sidebar-width (320). z-index: --mrx-z-nav|topbar|save-bar|popover|bulk-bar|modal|palette|toast.

The admin sets html { font-size: 62.5% }: use px or tokens, never rem.

Layout

<div class="mrx-layout">                   <!-- main + 320px sidebar; one column under 1024px -->
    <div class="mrx-layout__main">cards</div>
    <aside class="mrx-layout__aside">cards</aside>
</div>
<div class="mrx-layout mrx-layout--single">...</div>        <!-- one column -->
<div class="mrx-layout mrx-layout--annotated">              <!-- settings: description left, card right -->
    <div class="mrx-layout__annotation"><h2 class="mrx-heading mrx-heading--sm">Business details</h2><p>Shown on invoices.</p></div>
    <section class="mrx-card">...</section>
</div>
<div class="mrx-grid mrx-grid--4">KPI cards</div>           <!-- 2 (default), --3, --4 columns; collapses on small screens -->
<div class="mrx-stack">…</div>  <div class="mrx-stack mrx-stack--tight|--loose">   <!-- vertical gap 16 / 4 / 24 -->
<div class="mrx-inline">…</div> <!-- modifiers --tight, --between, --end, --nowrap -->

Card

<section class="mrx-card">
    <div class="mrx-card__header">
        <h2 class="mrx-card__title">Customer</h2>
        <a class="mrx-link mrx-card__action" href="...">Edit</a>          <!-- or <div class="mrx-card__actions">buttons</div> -->
    </div>
    <div class="mrx-card__section">...</div>
    <div class="mrx-card__section mrx-card__section--subdued">...</div>
    <div class="mrx-card__footer"><button class="mrx-btn mrx-btn--primary">Save</button></div>
</section>

mrx-card--flush removes section padding (tables, lists). mrx-card__subtitle for a secondary line under the title.

Buttons

mrx-btn + one of mrx-btn--primary (near-black), mrx-btn--secondary (default look, explicit class recommended), mrx-btn--plain, mrx-btn--critical. Sizes mrx-btn--sm (28px), default 32px, mrx-btn--lg (40px); mrx-btn--full for full width. Icon only: <button class="mrx-btn mrx-btn--secondary mrx-btn--icon" aria-label="Edit"><?= $icons->render('edit') ?></button>. Icon + text: put the icon first, wrap text in <span>. Loading: loader.button(button, promise) from Mrx_Light/js/loader (see Loading states below); it adds is-loading, aria-busy="true" and disabled and keeps the width (label made transparent but kept as the accessible name, spinner shown; primary and critical keep their colour even when also disabled). Static markup for a button that is loading on render: class="mrx-btn … is-loading" aria-busy="true" disabled. Disabled: disabled attribute. Groups: <div class="mrx-btn-group">; segmented control: mrx-btn-group mrx-btn-group--segmented with aria-pressed="true" / is-pressed on the active button. Disclosure chevron: $icons->render('chevron-down', 'mrx-btn__disclosure').

Form fields

<div class="mrx-field">
    <label class="mrx-field__label" for="product-title">Title</label>
    <input class="mrx-input" id="product-title" name="title" type="text">
    <p class="mrx-field__help">Customers see this in the store.</p>
</div>

<div class="mrx-field mrx-field--error">
    <label class="mrx-field__label" for="product-sku">SKU</label>
    <input class="mrx-input" id="product-sku" name="sku" aria-invalid="true" aria-describedby="product-sku-error">
    <p class="mrx-field__error" id="product-sku-error"><?= $icons->render('alert-circle') ?>This SKU is already in use.</p>
</div>

<div class="mrx-input-group">                                 <!-- prefix / suffix -->
    <span class="mrx-input-group__prefix">€</span>
    <input class="mrx-input" id="price" name="price" type="number" step="0.01">
    <span class="mrx-input-group__suffix">kg</span>
</div>

<select class="mrx-select" id="status" name="status">...</select>
<textarea class="mrx-input mrx-textarea" name="note" rows="4"></textarea>

<label class="mrx-checkbox">
    <input class="mrx-checkbox__input" type="checkbox" name="charge_tax" value="1">
    <span class="mrx-checkbox__label">Charge tax on this product</span>
    <span class="mrx-checkbox__help">Optional help line</span>
</label>

<label class="mrx-toggle">
    <input class="mrx-toggle__input" type="checkbox" role="switch" name="visible" value="1">
    <span class="mrx-toggle__track" aria-hidden="true"></span>
    <span class="mrx-toggle__label">Visible in the online store</span>
</label>

<fieldset class="mrx-choice-list">
    <legend class="mrx-field__label">Inventory</legend>
    <label class="mrx-radio">
        <input class="mrx-radio__input" type="radio" name="inventory" value="track" checked>
        <span class="mrx-radio__label">Track stock</span>
        <span class="mrx-radio__help">Stop selling when the stock runs out.</span>
    </label>
</fieldset>

<div class="mrx-form-row">two or more .mrx-field side by side (stacks on narrow cards)</div>

mrx-field__label--required adds a red asterisk (use sparingly: mark required fields only where it matters).

Badge

<span class="mrx-badge mrx-badge--success">Paid</span>. Tones: --neutral (default), --success, --info, --warning, --critical, --attention. Dot: <span class="mrx-badge mrx-badge--warning"><span class="mrx-badge__dot"></span>Payment pending</span>. Shipping progress: <span class="mrx-badge__progress mrx-badge__progress--incomplete|--partial|--complete"></span> inside the badge (Not shipped = attention + incomplete, Partially shipped = warning + partial, Shipped = neutral + complete). Add mrx-badge__progress--success to make the icon green (--mrx-tone-success-strong) while the badge stays grey: Paid and Shipped do, so a done order reads as done; Refunded does not.

Badge vocabulary. One word, one tone, in every module; add a row here before you introduce a new status word, and reuse an existing word before you invent a synonym. What the tones mean: success = live and working; info = a state worth noticing that needs nothing (new, scheduled for later, a marker such as "Main channel"); attention = the merchant has something to do; warning = incomplete or at risk; critical = broken or blocking a sale; neutral = finished, switched off or history. "plain" means no badge at all, just secondary text.

AreaWordTone
Status (products, discounts, users)Activesuccess
Draftinfo
Inactive, Expired, Not saved yetneutral
Scheduledattention
Visibility and channelsVisible, Online, Set upsuccess
Hidden, In menu, Not in menuneutral
Main channel, Default, Channel menu, You, Custom rateinfo
No channels, No productswarning
Offline, Not set up, No keyattention
Lockedcritical
StockSold outcritical
In stock, Not trackedplain
PaymentPaidneutral (progress complete, progress tone success)
Refunded, Partially refunded, Voidedneutral
Payment pending, Authorized, Partially paid, Incomplete paymentwarning
ShippingNot shippedattention (progress incomplete)
Partially shipped, On holdwarning (progress partial for Partially shipped)
Shippedneutral (progress complete, progress tone success)
Not required, No shipping needed, Canceledneutral
CustomersSubscribedsuccess
Pending confirmationinfo
Not subscribedneutral
ContentTemporary (302)info
Permanent (301), Page not found, Cookie noticeneutral
FeaturesBetaattention
Payment providersLivesuccess
Test mode, No methods onwarning
Not connected, Off at checkoutneutral
Status unknowncritical

The same English word also gets one translation per language Light ships across its core modules (section 13); the unit test Mrx\Light\Test\Unit\I18n\TranslationConsistencyTest enforces that.

Tabs

Panels switched in the page:

<div data-mage-init='{"Mrx_Light/js/tabs": {}}'>
    <div class="mrx-tabs" role="tablist" aria-label="Customer">
        <button type="button" class="mrx-tabs__tab" role="tab" id="t-orders" aria-controls="p-orders" aria-selected="true">Orders <span class="mrx-tabs__count">4</span></button>
        <button type="button" class="mrx-tabs__tab" role="tab" id="t-notes" aria-controls="p-notes" aria-selected="false" tabindex="-1">Notes</button>
    </div>
    <div class="mrx-tabs__panel" role="tabpanel" id="p-orders" aria-labelledby="t-orders">...</div>
    <div class="mrx-tabs__panel" role="tabpanel" id="p-notes" aria-labelledby="t-notes" hidden>...</div>
</div>

Link tabs (each a page): <nav class="mrx-tabs"><a class="mrx-tabs__tab" aria-current="page" href="...">General</a>…</nav>. The wrapper fires mrx:tabchange (detail: {tab, panel}).

Empty state, banner, thumbnail, avatar, tag, chip, kbd

<div class="mrx-empty-state">
    <div class="mrx-empty-state__icon"><?= $icons->render('products') ?></div>
    <h2 class="mrx-empty-state__heading">Add your products</h2>
    <p class="mrx-empty-state__text">Start by stocking your store with products your customers will love.</p>
    <div class="mrx-empty-state__actions"><a class="mrx-btn mrx-btn--primary" href="...">Add product</a></div>
</div>

<div class="mrx-banner mrx-banner--warning" role="status">          <!-- --info (default) | --success | --warning | --critical (role="alert") -->
    <span class="mrx-banner__icon"><?= $icons->render('alert') ?></span>
    <div class="mrx-banner__content">
        <p class="mrx-banner__title">This product uses Page Builder</p>
        <div class="mrx-banner__body">Edit the content in the advanced view.</div>
        <div class="mrx-banner__actions"><a class="mrx-btn mrx-btn--secondary mrx-btn--sm" href="...">Edit content in the advanced view</a></div>
    </div>
    <button type="button" class="mrx-btn mrx-btn--plain mrx-btn--icon mrx-banner__dismiss" data-mrx-dismiss aria-label="Dismiss"><?= $icons->render('x') ?></button>
</div>

<span class="mrx-thumbnail mrx-thumbnail--md"><img src="..." alt=""></span>         <!-- --xs 24 | --sm 32 | --md 40 | --lg 60 | --xl 80 -->
<span class="mrx-thumbnail mrx-thumbnail--md mrx-thumbnail--placeholder"><?= $icons->render('image') ?></span>
<span class="mrx-avatar">SV</span>  <span class="mrx-avatar mrx-avatar--sm|--lg">…</span>
<span class="mrx-tag">linen<button type="button" class="mrx-tag__remove" aria-label="Remove tag"><?= $icons->render('x') ?></button></span>
<span class="mrx-chip"><span class="mrx-avatar">SV</span><span>Sanne de Vries</span></span>
<kbd class="mrx-kbd">G</kbd>   <kbd class="mrx-kbd" data-mrx-mod-label>Ctrl</kbd>   <!-- becomes ⌘ on a Mac -->

data-mrx-dismiss hides the closest .mrx-banner/.mrx-card (or the element whose id is the attribute value).

Description list, timeline, skeleton, popover, text helpers

<dl class="mrx-dl">                                         <!-- term left, value right; mrx-dl--stacked puts them on two lines -->
    <div class="mrx-dl__row"><dt class="mrx-dl__term">Total spent</dt><dd class="mrx-dl__desc" data-mrx-money="1234.5"></dd></div>
</dl>

<div class="mrx-timeline">
    <form class="mrx-timeline__composer">
        <span class="mrx-avatar mrx-avatar--sm">RB</span>
        <label class="mrx-visually-hidden" for="note">Comment</label>
        <input class="mrx-input" id="note" name="comment" placeholder="Leave a comment...">
        <button type="submit" class="mrx-btn mrx-btn--secondary">Post</button>
    </form>
    <ol class="mrx-timeline__list">
        <li class="mrx-timeline__date">Today</li>
        <li class="mrx-timeline__item">
            <span class="mrx-timeline__dot mrx-timeline__dot--strong" aria-hidden="true"></span>
            <div class="mrx-timeline__content">
                <p class="mrx-timeline__text">Order confirmation email was sent.</p>
                <time class="mrx-timeline__time" datetime="2026-09-22T12:05:00+00:00" data-mrx-date="2026-09-22T12:05:00+00:00"></time>
            </div>
        </li>
    </ol>
</div>

<span class="mrx-skeleton mrx-skeleton--heading|--text|--short|--thumbnail|--button|--checkbox|--media"></span>

<div class="mrx-popover-wrap">
    <button type="button" class="mrx-btn mrx-btn--secondary" data-mrx-popover-trigger="order-more" aria-haspopup="menu" aria-expanded="false" aria-controls="order-more">More actions<?= $icons->render('chevron-down', 'mrx-btn__disclosure') ?></button>
    <div class="mrx-popover mrx-popover--end" id="order-more" role="menu" hidden>          <!-- --end aligns right, --up opens upwards -->
        <div class="mrx-popover__section">
            <button type="button" class="mrx-popover__item" role="menuitem"><?= $icons->render('print') ?><span>Print packing slip</span></button>
        </div>
        <div class="mrx-popover__section">
            <button type="button" class="mrx-popover__item mrx-popover__item--critical" role="menuitem"><?= $icons->render('trash') ?><span>Cancel order</span></button>
        </div>
    </div>
</div>

Timeline date groups ("Today", "Yesterday", "12 September") must be computed in the store time zone (Magento\Framework\Stdlib\DateTime\TimezoneInterface::date()), not UTC, or items after midnight land in the wrong group; the <time data-mrx-date> labels already use the store time zone.

Popovers need no JS of your own: the shell handles open/close, outside click, Esc, arrow keys/Home/End, and closes the menu when Tab or a click moves focus out of it, for every [data-mrx-popover-trigger]. Items get tabindex="-1" while the menu is open (roving focus: arrows move, Tab leaves). Items show a visible focus ring everywhere, including inside page content.

Text: mrx-heading (16/600), mrx-heading--sm (13), mrx-heading--lg (20), mrx-text--secondary, --strong, --sm, --critical, --success, --mono, --tabular, mrx-truncate, mrx-visually-hidden, mrx-link, mrx-link--monochrome, mrx-link--button (a <button> that looks like a link), mrx-divider, mrx-spinner (--sm).

Loading states

Never show the word "Loading" as visible text. Anything that waits for a request uses Mrx_Light/js/loader: a region gets a skeleton shaped like what is coming, a button gets its spinner. The kit page (/light/kit, card "Loading") shows every preset.

define(['Mrx_Light/js/api', 'Mrx_Light/js/loader'], function (api, loader) {
    var results = document.querySelector('[data-my-results]');

    // A region: skeleton after 200 ms, removed the moment the work is done.
    loader.during(results, api.get(url, {q: query}), {preset: 'list', count: 5})
        .then(render, function (error) {
            showError(error.message); // the skeleton is already gone
        });

    // A button: spinner, aria-busy and disabled at once, the width stays.
    loader.button(saveButton, function () {
        return api.post(saveUrl, data);
    });
});
CallWhat it does
loader.during(region, promiseOrFn, options) → PromiseSets aria-busy="true" on region at once. After delay (200 ms) it hides the target's children, shows the skeleton in their place, reserves the target's current height (or height) and says "Loading" once through a visually hidden live region (label changes the words). Once the work is done the skeleton goes at once (minimum, 0 ms by default; pass a minimum for content that should not flash). Then it removes the skeleton, aria-busy and the reserved height, puts back focus that was inside the target, and resolves or rejects with what promiseOrFn gave (a function is called at once). A second call on the same region while the first is running takes over: the first one's result no longer ends the loading, which suits a search that aborts its previous request.
loader.button(button, promiseOrFn) → Promiseis-loading, aria-busy and disabled straight away, min-width fixed at the current width; everything back afterwards (a button that was disabled stays disabled), focus returns to the button when it had it. Themes keep styling .mrx-btn.is-loading::after.
loader.skeleton(preset, options) → ElementOnly the markup (.mrx-loader.mrx-loader--<preset>, aria-hidden), for a placeholder you place yourself.
loader.spinner({size: 'small', label}) → ElementA .mrx-spinner for a small area that has no shape to copy. Without label it is aria-hidden.

Options of during (and skeleton): preset, count, columns and checkbox (table), lines (card), thumbnail: false (list), height (px to reserve), target (where the skeleton goes, when it isn't the busy region), label, delay, minimum. Without preset, during only sets aria-busy and announces; use that when the content swaps in place (an order page after an action).

PresetShapeUse it for
listrows of a 32px thumbnail and two text lines (count, 5)search results, pickers, the palette, product lists in a card
tablegrid rows with a checkbox and columns cells (count, 5); inside a <tbody> target it adds real <tr> rows with the table's column countindex tables and other tables; the Light list does this itself
carda heading and lines text lines (count cards, 1)popovers, summaries, a modal body such as an email preview
textcount lines, the last one shorter (3)a short summary or a line of totals
mediaa grid of square tiles (count, 4)image grids and uploads

Rules: pick the preset that matches the final layout and a count near the rows you expect, so nothing jumps when the content lands. Don't use a skeleton for background checks (an availability check, autocompletion that keeps its list): those stay silent. With prefers-reduced-motion the skeleton doesn't pulse and spinners turn slowly. Every colour comes from --mrx-color-border and the text tokens, so every theme styles it without extra rules.

Advanced section (end of a settings page)

Every settings page ends with the same section for what only the advanced view changes: the heading "Advanced" and "Settings you rarely need. You can only change them in the advanced view." on the left, on the right a card with read-only values and, in the footer, the button "Edit these settings in the advanced view". Don't build it by hand and don't end a page with a loose AdvancedHint banner: render Mrx_Settings::advanced-section.phtml (marker [data-mrx-advanced-section]) as the last child of the form.

<?= /* @noEscape */ $block->renderPartial('Mrx_Settings::advanced-section.phtml', [
    'rows' => [['label' => (string)__('Orders that can be returned'), 'value' => 'Complete']],   // read-only, may be empty
    'note' => $advanced->getNote(43),                                                                 // optional line under the rows
    'url' => $advanced->getUrl('rma', $scope->getChannelId()),                                        // the stock section, in the advanced view
]) ?>

Mrx\Settings\ViewModel\AdvancedSection builds the parts: getUrl($sectionId, $channelId) (the stock section with mrx_stock=1, so the banner of the advanced view leads back to the page), getNote($count) ("43 more settings are in the advanced view.") and getRows() for schema fields. A value reads as the admin would read it: an option label for a select, Yes or No for a switch, ****** for a secret (an obscure or password field, or a field with an Encrypted backend model), "Not set" for an empty value, a long text cut at 120 characters. Rows are values, not controls: no inputs in the card. Pages generated from system.xml get the section from the page item's advanced key (A settings page from your system.xml).

Payment provider card (Settings)

Settings > Payments draws one card per payment provider module, the same for every provider (Mrx_Settings::page/payments/provider-card.phtml). The module supplies data through Mrx\Settings\Api\PaymentProviderCardInterface and two forms; core draws the rest (Payment providers). A provider never draws its own header, badge or dashboard link.

From top to bottom:

  • Header: logo (20px high, at most 64px wide, else the payment icon), name, status badge. On the right, Connect while the provider isn't connected, else the ⋯ menu (More actions: "Open the %1 dashboard", "Manage payment methods", "Edit connection").
  • The account line (getAccountName()), or while not connected the item's description.
  • In test mode a warning banner, "Test mode is on. Orders placed now aren't really paid, so don't ship them.", with Turn off test mode.
  • The method strip: up to six logos of methods that are on, then "+N", then "%1 of %2 methods on".
  • Three closed <details> panels with 44px summary rows: Payment methods (child <code>_methods, then "Manage payment methods"), Connection (child <code>_connection) and Advanced (up to six read-only rows and "Edit these settings in the advanced view").

An admin without the item's resource sees the badge, account line and strip, and "The store owner connects %1 and chooses its payment methods." instead of the panels, Connect and the menu.

StatusWordTone
not_connectedNot connectedneutral
offOff at checkoutneutral
testTest modewarning
no_methodsNo methods onwarning
liveLivesuccess
anything else, or a card that throwsStatus unknowncritical

Hooks and classes:

  • data-mrx-payments-band="online|manual" on each band; data-mrx-provider="<code>" and data-mrx-provider-status on the card; data-mrx-provider-panel="methods|connection|advanced" on each panel.
  • data-mrx-provider-test-mode goes on the module's own test-mode switch: Turn off test mode opens Connection and focuses it.
  • #payments-<code>-connect opens the card's Connection panel on load.
  • mrx-provider-card* (header, logo, action, account, panel, summary, panel body), mrx-method-strip* (list, method, more, count) and mrx-payments-methods* (the switch list with logos for a methods form) live in Mrx_Settings/css/settings.css.
  • Mrx_Settings/js/payment-providers opens the panels; the forms and their saving stay the module's.

Emails & documents screen (Settings)

Settings > Emails & documents (Mrx_Documents::settings/documents.phtml) has three patterns that no other screen shares yet. The classes belong to this screen: Mrx_Documents/css/documents.css loads only on the layout handle light_settings_documents, and the mrx-documents-* classes are not part of the versioned API surface, so they can change without notice. A module with a similar choice or preview copies the pattern (radio cards driven by :has(), a sticky preview card, a picker that swaps between email and PDF) and styles it with its own classes.

Format cards. A choice between a few looks is a row of radio cards, not a select. Each card is a label.mrx-documents-format around a visually hidden radio (mrx-documents-format__input), a 4:3 thumbnail (__preview, an aria-hidden span that holds an img with an empty alt, and stays empty when the format has no thumbnail), a name and one line of description (__body, __name, __description), and a check mark in a circle in the text colour (__check). Cards sit in mrx-documents-formats__grid, which fills the row with columns of at least 132px and wraps on a narrow screen. The state comes from the radio through :has(): a 2px --mrx-color-text ring and the check mark when checked, a focus ring when the radio has keyboard focus, 60% opacity when it is disabled (a locked setting). The description of each card is the radio's aria-describedby, and the legend of the fieldset carries the field label. Thumbnails come from the item's thumbnail in the pool, a module-relative image.

Preview pane. The right column is a sticky card (mrx-documents__preview, 1.15 times the width of the settings column; under 1024px the two stack and the pane stops being sticky). The header holds a segmented "Desktop | Phone" control (mrx-btn-group--segmented, aria-pressed). Under it is one grouped picker: a select.mrx-select with one optgroup per email group (Orders, Account, Returns), an "Other modules" group for emails without a group and for other modules' customer templates, and a "Documents" group for the PDFs, each labelled " (PDF)". A group only shows when it holds options the admin's permissions allow. A PDF in the picker swaps the email frame for a PDF frame, hides the Desktop | Phone control, disables the test-send button and shows the note "PDFs are always light."; an email shows the note about dark mode. The stage (__stage, grey surface, at most 70vh, scrolls) keeps its scroll position while the next version loads and dims the frame to 60% (is-loading). The email frame is sandboxed, 600px wide on Desktop and 390px on Phone, scaled to fit, and forced to color-scheme: light, so the page's own scheme never switches the mail's dark rules on. The preview re-renders about 400 ms after each edit, before the owner saves. "Send test email" sits in the card footer.

Check-first bulk print. A bulk action that prints a PDF asks the server first how much there is to print, so nobody gets an empty tab. The action uses mode download for the plain case, and the list's JavaScript takes over the click: it POSTs ids[] and check=1, and the controller answers {success, printable, skipped, url, message}. With printable above 0, the table opens url in a new tab (the toast gets an "Open" button when the browser blocks the tab) and toasts message ("12 invoices ready to print, 2 orders skipped because they have no invoice yet"). With 0, no tab opens: the toast says why nothing can print. The controller serves the same route as a plain GET with ids=1,2,3 for the link, and caps the selection at 250 orders. The built-in users are "Print invoices", "Print picklist" and "Print order confirmations" on Orders; the handler is bulkCheckedPrint() in Mrx_Orders/js/orders-index.js.

Touch targets on phones

Under @media (max-width: 767px) and (pointer: coarse) Light makes its own controls 44px touch targets: buttons (all sizes, icon buttons 44×44), inputs and selects, checkbox and radio labels, tabs, sortable headers, the table's checkbox column (the whole cell toggles the row; the header cell toggles "select all"), popover items, nav links, top-bar controls, and links that stand on their own line (a direct child of .mrx-stack, .mrx-inline, .mrx-card__section, .mrx-card__header, .mrx-card__actions or .mrx-card__footer, plus .mrx-card__action), buttons and links inside IndexTable cells (button that is not an .mrx-btn, a.mrx-index-table__cell-link, a.mrx-link: 44px high, still one truncated line), and the tag remove button (18×18 drawn, 44×44 hit area through ::after). Tight inline groups get 8px gaps. A mouse, at any width, keeps the desktop density. Links inside running text keep their line height. Feature CSS that sets its own sizes on phones (card layouts, custom toggles, <summary>, file inputs) must give its controls the same 44px under that media query.

16px text in fields on phones. Under @media (pointer: coarse), (max-width: 640px) every text field on an admin page gets 16px text: inputs of a text type, selects and textareas, Light's and the stock ones, in the page, a modal, the bulk bar and the palette. iOS Safari zooms into a field whose text is smaller when it gets the focus and stays zoomed, so the rule is one !important floor in light.css rather than a size per component; login.css repeats it for the sign-in, two-factor and password-reset cards. Don't size a field below 16px for phones, and don't stop the zoom through the viewport meta (that blocks pinch zoom for everyone). A mouse on a wide screen keeps the desktop sizes (--mrx-font-size-md).

Icons

Mrx\Light\ViewModel\Icons (pass as layout argument icons): $icons->render('orders', 'extra-class', 'Accessible label') returns an inline 20×20 SVG (stroke 1.5, currentColor, aria-hidden unless you pass a label). Same set in JS: require(['Mrx_Light/js/icons'], icons => icons.render('x')).

Names: home, orders, products, customers, content, discounts, settings, search, chevron-down, chevron-up, chevron-left, chevron-right, arrow-left, x, menu, plus, minus, check, external, store, more, mail, info, alert, alert-circle, check-circle, trash, edit, image, copy, calendar, sort, sort-asc, sort-desc, keyboard, sparkle, location, clock, filter, toggle, logout, question, truck, payment, note, print, refund, eye, drag, globe, bell, receipt, lock, phone, code, link, bold, italic, list-bullet, list-number, clear-format, heading, star, chart, users, columns, apps (2×2 grid), return (U-turn arrow). The kit shows them all in its "Icons" card. An unknown name renders info. A module adds its own icons through the Icons argument paths (section 17.4.8); they render the same way in PHP and JS.

Search result preview (Google)

Block Mrx\Light\Block\SearchPreview (template Mrx_Light::search-preview.phtml, CSS mrx-serp in light.css, JS Mrx_Light/js/search-preview) draws one Google result for one language: the shop's browser icon on a round badge (Google's globe when the storefront has none), the site name, the address as a breadcrumb with the three-dot icon, the title as a blue link line and the description. The product, category and page editors all use it in their "SEO" card, and the UI kit shows it in a card of that name; use it for anything else a search engine lists.

It copies Google on purpose, so it uses Google's own type (Arial) and colours instead of the theme tokens: Google's light page in a light theme, Google's dark mode when html[data-mrx-scheme="dark"] (Noordzee, Nachtwacht). The favicon badge stays a light circle in both, as on Google. Themes should not restyle .mrx-serp.

<?= /* @noEscape */ $block->getLayout()->createBlock(\Mrx\Light\Block\SearchPreview::class, '', ['data' => [
    'store_id' => 19,                               // the language; 0 or unknown = the default channel's main language
    'path' => 'bank-helsinki.html',                 // after the base URL; '' for a home page
    'title' => $metaTitle ?: $name,                 // plain text; the storefront's title prefix and suffix are added
    'description' => $metaDescription,              // plain text, or 'description_html' to use the text of HTML
    'empty_text' => (string)__('Add a title to see how this product might appear in a search engine listing.'),
    'html_id' => 'product-search-preview',
]])->toHtml() ?>
PartRule
Site namegeneral/store_information/name of that store view, else its store group's name (Magento's Store::getFrontendName())
IconMrx\Light\Model\SearchPreview\Favicon::getUrl($storeId): the uploaded design/head/shortcut_icon for that store view (Content > Design or Settings > Branding, per channel), else a Magento_Theme/web/favicon.ico in the store's own theme, else '' and the globe. Magento's stock icon counts as none. The URL uses the admin's media URL, so it loads under the admin's content security policy. An icon that fails to load also shows the globe.
Addressthe language's base URL (origin) and every path segment joined by ›, the last .html/.htm left off, query and fragment dropped: https://app.magerexlight.test › wonen › banken
Titledesign/head/title_prefix + title + design/head/title_suffix of that store view, cut at a word with ... to at most 60 characters
Descriptioncut at a word with ... to at most 160 characters; hidden when empty
Emptyno title: the result is hidden and empty_text shows instead

The JSON in data-mrx-search-preview carries every language's site (sites, keyed by store id), so the JS can switch channel and language without a request. PHP: Mrx\Light\Model\SearchPreview\SiteProvider::get($storeId): Site (storeId, name, faviconUrl, baseUrl, titlePrefix, titleSuffix, toArray()), getAll(), resolveStoreId(); Formatter::breadcrumb($baseUrl, $path), title($title, $prefix, $suffix), description($text), truncate($text, $length), plainText($html).

define(['Mrx_Light/js/search-preview'], function (searchPreview) {
    var preview = searchPreview.get(card.querySelector('[data-mrx-search-preview]'));

    preview.update({storeId: 19, path: handle + '.html', title: title, description: description});   // any subset
    preview.getState();                            // {storeId, path, title, description, site}
    searchPreview.breadcrumb('https://shop.test/', 'a/b.html');   // "https://shop.test › a › b"
    searchPreview.truncate(text, searchPreview.DESCRIPTION_LENGTH);
});

Markup hooks for tests: [data-mrx-seo-preview] (the result), [data-mrx-seo-favicon] (img), [data-mrx-seo-globe], [data-mrx-seo-site], [data-mrx-seo-url], [data-mrx-seo-title], [data-mrx-seo-description], [data-mrx-seo-empty]. tests/playwright/helpers/search-preview.ts has breadcrumb(url), expectFavicon(preview) and expectTruncated(preview).


5. JavaScript modules

All modules are RequireJS AMD under Mrx_Light/js/*, use mage/translate for strings and need no Knockout. Initialise your own code with data-mage-init / x-magento-init and make it idempotent (guard with a WeakSet of bound elements, like the Light modules do). Don't move stock DOM around and don't use MutationObservers on the page.

ModuleAPI
Mrx_Light/js/configconfig.get(key, fallback), config.url(name), config.all(). Keys: mode, stockView and apiVersion (section 12), locale (nl-NL), currency (base currency of the default store), timezone, storeName, sender {name, email}, user {username, name, email}, canContact, urls {modeSet, search, contact, resolve}, nav {key: {label, url}}, shortcuts [{keys, label, url}], channels {multi, multiLanguage, defaultId, list} (section 16), icons {name: svg} (icons registered through the Icons argument paths; the built-ins ship in icons.js).
Mrx_Light/js/apiapi.get(url, params, {signal, toastOnError}), api.post(url, data, {toastOnError}), api.request(url, {method, data, params, signal, toastOnError}) → Promise<json>. Adds form_key to writes (window.FORM_KEY), isAjax=true and X-Requested-With; arrays become ids[]=1&ids[]=2; a FormData body is passed through. Rejects with ApiError {message, status, data} on non-2xx, {error: true}, {success: false} or a non-JSON body; the message is the server's message. A session timeout or an open second factor loads the sign-in page as a whole page, never inside a modal or a list: an ajaxExpired answer (Magento's, or Light's 401 {error, ajaxExpired, ajaxRedirect, message} for a session that isn't fully signed in) follows its ajaxRedirect, and a request that Magento redirected to the sign-in page (the answer is HTML with body.mrx-login) reloads the page, which then shows that screen. Both reject with "Your session has expired. Sign in again."
Mrx_Light/js/toasttoast.show(message, {tone: 'critical', action: {label, onAction}, duration}), toast.error(message). Copy: 3 words, noun + verb ("Order shipped").
Mrx_Light/js/modalmodal.open({title, content: Element|'text', size: 'small'|'medium'|'large', primaryAction: {label, tone, disabled, onAction(handle) → Promise|false}, secondaryActions: [{label, onAction}], initialFocus, closeOnBackdrop, isDirty() → bool, dirtyConfirm: {title, message, confirmLabel}, onClose}) → handle {element, body, primaryButton, close(), submit(), setLoading(bool), setError(msg), clearError(), setPrimaryDisabled(bool)}. A promise from onAction puts the primary button in the loader's spinner state (loader.button, so is-loading, aria-busy and disabled for as long as it runs, and handle.setLoading(bool) does the same by hand); resolve closes (resolve to false keeps it open), reject shows the error message inside the modal. modal.confirm({title, message, confirmLabel, cancelLabel, tone}) → Promise<boolean>; with tone: 'critical' the Cancel button gets the initial focus, so Enter never confirms a destructive action by accident. modal.isOpen(). Focus is trapped, Esc, the backdrop and the close button dismiss it, focus returns afterwards. Pass isDirty on modals with a form: when it returns true, those three paths first ask "Discard unsaved changes?" (wording from dirtyConfirm); your own Cancel button still closes directly. On phones (up to 599px) every modal is a bottom sheet: it slides up over --mrx-duration-sheet with --mrx-ease-in-out while the backdrop fades in, shows a grab handle (.mrx-sheet-handle), and slides back down before it leaves the DOM on every close path (Esc, backdrop, close button, your actions, handle.close()). Dragging the handle or the header follows the finger; past 80px or a quick flick it dismisses like the close button (so isDirty still asks first, and the sheet springs back if it stays); otherwise it springs back. The body scrolls as usual. While it slides out the root has .is-closing and is inert; the stack, focus and onClose are already done by then. On wider screens it keeps the short fade-in and closes at once; with reduced motion it doesn't slide. Every Light modal gets this; there is nothing to opt into.
Mrx_Light/js/save-barsaveBar.attach(form, {onSave(form) → Promise, onDiscard(form), message, alwaysVisible}) → {save(), discard(), isDirty(), markClean(), check(), destroy()}; saveBar.get(form). Tracks every named control, shows the black "Unsaved changes" bar with Discard/Save, saves on Cmd/Ctrl+S, on form submit, treats form reset as discard, asks "Discard all unsaved changes?" before the Discard button throws away edits (every form; there is no option to skip it, and a form without changes discards at once), asks before in-page links leave (data-mrx-no-guard opts a link out) and sets the browser's leave prompt. Resolve onSave to mark the form clean; reject with an Error to show its message as a critical toast. Use alwaysVisible: true, message: $t('Unsaved product') on create pages. Script that changes a field must dispatch input or change on it. Discard restores every field and dispatches mrx:valuechange (bubbling) on each one, not input: a custom widget that mirrors a hidden field (like rich-text) must listen for mrx:valuechange to redraw. File inputs are not tracked; mark the form dirty yourself (dispatch input on a hidden field) after an upload.
Mrx_Light/js/popoverInstalled by the shell. popover.open(trigger, focusFirst), close(returnFocus), toggle(trigger) if you need it from code.
Mrx_Light/js/tabsSee Tabs above.
Mrx_Light/js/rich-textdata-mage-init='{"Mrx_Light/js/rich-text": {"placeholder": "..."}}' on a <textarea name="description">. Toolbar: paragraph/heading/subheading, bold, italic, bulleted and numbered list, link (Cmd/Ctrl+K inside the editor), insert image (see below), clear formatting, HTML source toggle. Insert image opens a small dialog (choose a JPG, PNG, GIF or WebP file, alt text), uploads it to POST light/media/upload (field image, ACL Magento_Cms::media_gallery, through the stock wysiwyg storage into pub/media/wysiwyg/, so it also shows up in the Media Gallery) and inserts <img src="/media/wysiwyg/name.jpg" alt="..."> at the caret. The URL is root-relative, so the same HTML works on every channel's domain. The button shows for page and block editors (a textarea inside [data-mrx-content-editor]), for any textarea inside [data-mrx-rte-images], or with richText.create(textarea, {images: true}); {images: false} hides it. It never shows when the admin lacks the Media Gallery permission (urls.imageUpload in the shell config is then empty). Allowed output: p, h2-h4, strong, em, u, s, ul, ol, li, a[href] (http, https, mailto, tel, relative), br, blockquote, code, pre, img[src]. In the visual editor the textarea always holds sanitised HTML and fires input, so the save bar and forms just work. In HTML-source mode the textarea holds exactly what was typed; the editor cleans it on the way out (the form's formdata event, which fires for new FormData(form) and for real submits). So read the value with new FormData(form) or getHtml(), never with textarea.value. The browser sanitiser is a convenience, not a security boundary: the controller must still filter the HTML (allowlist the same tags, e.g. with Magento\Framework\Filter\... or your own DOMDocument pass). Programmatic: richText.create(textarea, options) → {getHtml(), setHtml(html), focus(), destroy()} (destroy() removes every listener), richText.sanitize(html). For AI output call setHtml(). Detect Page Builder content (data-content-type) server-side and show the banner + "Edit in Page Builder" instead of this editor.
Mrx_Light/js/formatformat.money(amount, currency?), format.number(n, intlOptions?), format.date(iso) → "Today at 14:05" / "Yesterday at 09:12" / "3 days ago" / a short date ("12 sep." in nl-NL, "Sep 12" in en-US; the year is added when it differs), format.dateShort(iso), format.dateTime(iso), format.time(iso), format.parse(value), format.hydrate(root). Uses the admin locale, base currency and store time zone. Send dates as ISO 8601 with an offset (DateTimeInterface::ATOM); Y-m-d H:i:s strings are read as UTC. In markup, data-mrx-date="<iso>" and data-mrx-money="12.5" (+ optional data-mrx-currency) are filled on page load; call format.hydrate(element) after inserting new markup.
Mrx_Light/js/contact-customercontactCustomer.open({email, name, customerId, orderId, subject, sender, onSent(response)}). sender ({name, email}) is the "From" line shown in the modal, e.g. the channel identity of an order's or customer's channel; without it the default channel's sender shows. Any element with data-mrx-contact='{"email": "...", "name": "...", "customerId": 5, "orderId": 16, "subject": "...", "sender": {"name": "...", "email": "..."}}' opens it on click, in both modes, anywhere (a delegated listener). After sending: toast "Email sent" and a mrx:customer-message-sent document event (detail: {options, response}) so a timeline can refresh. Render these only when the admin has Mrx_Light::contact_customer ($block->getAuthorization()->isAllowed(...) or config.canContact). Pass the email that belongs to customerId/orderId (or leave it out): the server refuses any other address (section 11).
Mrx_Light/js/loaderloader.during(region, promiseOrFn, {preset: 'list'|'table'|'card'|'text'|'media', count, columns, checkbox, lines, height, target, label, delay, minimum}), loader.button(button, promiseOrFn), loader.skeleton(preset, options), loader.spinner({size, label}). See Loading states in section 4.
Mrx_Light/js/index-tableSee section 7. indexTable.get(rootElement) returns the instance (reload()).
Mrx_Light/js/form-hooksregister(formCode, code, hooks) with validate, payload and saved: an extension's browser hooks on the seven Light editors (section 6). How to use them: Fields on a Light editor.
Mrx_Light/js/channels, language-switcher, channels-card, channel-scope, use-defaultChannels and languages, section 16. config.get('channels') holds every channel with its languages.
Mrx_Light/js/search-previewSee "Search result preview" in section 4. searchPreview.get(element) → {update(data), getState()}; breadcrumb(baseUrl, path), truncate(text, length), title(text, site).
Mrx_Light/js/command-palettepalette.open(query?), close(), isOpen(). Opened by Cmd/Ctrl+K, /, and [data-mrx-palette-open].
Mrx_Light/js/shortcutsshortcuts.showHelp(); [data-mrx-shortcuts-help] opens it. Sequences come from nav items' shortcut.
Mrx_Light/js/icons, Mrx_Light/js/domicons.render(name, class, label) (class and label are escaped); dom.el(tag, attrs, children), dom.uid(prefix), dom.escapeHtml(s), dom.safeUrl(url) (returns the URL when it is http:, https:, mailto:, tel:, a /path or a #fragment, else ''; run every URL that came from data through it before href, src, location.href or window.open), dom.isTypingTarget(el), dom.isMac().

The shell (Mrx_Light/js/shell, loaded once on every admin page) sets html[data-mrx-ready="true"] and fires mrx:ready when popovers, the contact listener, formatting, the mode switch and (simple mode) the palette and shortcuts are installed. In simple mode the mobile nav drawer traps focus and locks page scroll while open.

Do not add a requirejs-config.js unless you really need a map or shim: in developer mode a new one only takes effect after deleting pub/static/adminhtml/*/*/*/requirejs-config.js and pub/static/_requirejs.


6. A form with the save bar and a JSON controller

Template:

<form class="mrx-stack" id="customer-form" data-mage-init='<?= $escaper->escapeHtmlAttr(json_encode([
    'Mrx_Customers/js/customer-form' => ['saveUrl' => $block->getUrl('light/customers/save', ['id' => $viewModel->getId()])],
])) ?>' novalidate>
    <section class="mrx-card">
        <div class="mrx-card__section mrx-stack">
            <div class="mrx-field">
                <label class="mrx-field__label" for="customer-firstname"><?= $escaper->escapeHtml(__('First name')) ?></label>
                <input class="mrx-input" id="customer-firstname" name="firstname" value="<?= $escaper->escapeHtmlAttr($viewModel->getFirstname()) ?>">
            </div>
        </div>
    </section>
</form>

view/adminhtml/web/js/customer-form.js:

define([
    'mage/translate',
    'Mrx_Light/js/api',
    'Mrx_Light/js/toast',
    'Mrx_Light/js/save-bar'
], function ($t, api, toast, saveBar) {
    'use strict';

    var bound = new WeakSet();

    return function (config, form) {
        if (bound.has(form)) {
            return;
        }
        bound.add(form);

        saveBar.attach(form, {
            onSave: function () {
                return api.post(config.saveUrl, new FormData(form)).then(function (response) {
                    toast.show(response.message || $t('Customer updated'));
                });
            }
        });
    };
});

Rejections from api.post carry the server message and the save bar shows it as a critical toast. For inline field errors return {success: false, message, errors: {field: 'text'}} with HTTP 422 and read error.data.errors in a .catch() that re-throws.

Controller (writes are POST-only; the form key is checked by Magento because api sends it):

<?php

declare(strict_types=1);

namespace Mrx\Customers\Controller\Adminhtml\Customers;

use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Customer\Api\CustomerRepositoryInterface;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\Controller\Result\Json;
use Magento\Framework\Controller\ResultFactory;
use Magento\Framework\Exception\LocalizedException;

class Save extends Action implements HttpPostActionInterface
{
    public const ADMIN_RESOURCE = 'Magento_Customer::manage';

    public function __construct(
        Context $context,
        private readonly CustomerRepositoryInterface $customerRepository
    ) {
        parent::__construct($context);
    }

    public function execute(): Json
    {
        /** @var Json $result */
        $result = $this->resultFactory->create(ResultFactory::TYPE_JSON);
        try {
            $customer = $this->customerRepository->getById((int)$this->getRequest()->getParam('id'));
            $customer->setFirstname(trim((string)$this->getRequest()->getParam('firstname')));
            $this->customerRepository->save($customer);
        } catch (LocalizedException $e) {
            return $result->setHttpResponseCode(422)->setData(['success' => false, 'message' => $e->getMessage()]);
        }

        return $result->setData(['success' => true, 'message' => (string)__('Customer updated')]);
    }
}

Delete lives in More actions. An editor's delete is a critical item in the page header's More actions menu (addMoreAction(['label' => (string)__('Delete page'), 'icon' => 'trash', 'tone' => 'critical', 'id' => 'mrx-page-delete', 'sort_order' => 90]), last in the menu), wired in the form's JS to a modal.confirm with tone: 'critical'. Don't add a second delete button below the form: the product, category, customer, discount, page and block editors all delete from More actions only. Leave the item out when the record can't be deleted (a home page, a channel's menu root) instead of showing it disabled; lists delete through a critical bulk action (section 7).

Editor save response (contract). The seven Light editors with a form code (product, category, cms_page, cms_block, discount, customer_new and customer) answer a successful save with at least {"success": true, "message": "...", "entity_id": 12, "ext": {...}}. ext is always an object: ext.<code> holds what that extension's save() returned, and it is empty when no extension saved anything. Everything else in the body (product, id, redirect and the like) belongs to the editor and may change in any release. A failed validation answers 422 with {"success": false, "message": "...", "errors": {...}}; an extension's field errors use the key ext.<code>.<field>, and its card shows them under the field. After a save the editor's form dispatches mrx:form-saved (it bubbles) with detail: {formCode, entityId, response}; mrx:product-saved stays for the product editor. Extension fields post as ext[<code>][<field>], and their browser hooks register through Mrx_Light/js/form-hooks (section 5). The four keys and the event are tier 1 (spec 8.1). How to add a card: Fields on a Light editor.

JSON response contract for every Light endpoint (the built-in ones follow it too: table data, search, mode, contact, url resolve): success → HTTP 200 {"success": true, "message": "...", ...}; failure → 4xx/5xx with {"success": false, "message": "..."} (422 validation, 403 permission, 404 missing, 500 unexpected; log the exception, never return its trace). GET endpoints implement HttpGetActionInterface; state changes are POST only. Always set ADMIN_RESOURCE. Reading scalar params: check is_scalar() before casting (arrays in params would otherwise raise a PHP warning, which Magento turns into an exception).


7. IndexTable: provider + page

Provider

<?php

declare(strict_types=1);

namespace Mrx\Orders\Model\IndexTable;

use Magento\Backend\Model\UrlInterface;
use Magento\Framework\Api\FilterBuilder;
use Magento\Framework\Api\SearchCriteriaBuilderFactory;
use Magento\Framework\Api\SortOrder;
use Magento\Framework\Api\SortOrderBuilder;
use Magento\Sales\Api\OrderRepositoryInterface;
use Mrx\Light\Api\IndexTable\ProviderInterface;
use Mrx\Light\Model\IndexTable\Query;

class Orders implements ProviderInterface
{
    private const SORT_FIELDS = ['order' => 'increment_id', 'date' => 'created_at', 'total' => 'base_grand_total'];

    public function __construct(
        private readonly OrderRepositoryInterface $orderRepository,
        private readonly SearchCriteriaBuilderFactory $searchCriteriaBuilderFactory,
        private readonly FilterBuilder $filterBuilder,
        private readonly SortOrderBuilder $sortOrderBuilder,
        private readonly UrlInterface $url
    ) {
    }

    public function getAclResource(): string
    {
        return 'Magento_Sales::sales_order';
    }

    public function getColumns(): array
    {
        return [
            ['key' => 'order', 'label' => (string)__('Order'), 'type' => self::TYPE_TEXT, 'sortable' => true, 'primary' => true],
            ['key' => 'date', 'label' => (string)__('Date'), 'type' => self::TYPE_DATE, 'sortable' => true],
            ['key' => 'customer', 'label' => (string)__('Customer'), 'type' => self::TYPE_LINK],
            ['key' => 'total', 'label' => (string)__('Total'), 'type' => self::TYPE_MONEY, 'sortable' => true],
            ['key' => 'payment', 'label' => (string)__('Payment status'), 'type' => self::TYPE_BADGE],
        ];
    }

    public function getTabs(): array
    {
        return [
            ['key' => 'all', 'label' => (string)__('All')],
            ['key' => 'not_shipped', 'label' => (string)__('Not shipped')],
        ];
    }

    public function getDefaultSort(): array
    {
        return ['key' => 'date', 'direction' => 'desc'];
    }

    public function getSearchPlaceholder(): string
    {
        return (string)__('Search orders');
    }

    public function getBulkActions(): array
    {
        return [
            ['key' => 'paid', 'label' => (string)__('Mark as paid'), 'route' => 'light/orders/massPaid',
                'resource' => 'Magento_Sales::invoice'],
            ['key' => 'slips', 'label' => (string)__('Print packing slips'), 'route' => 'light/orders/massPackingSlips',
                'mode' => 'download', 'target' => '_blank'],
            ['key' => 'cancel', 'label' => (string)__('Cancel orders'), 'route' => 'light/orders/massCancel',
                'resource' => 'Magento_Sales::cancel', 'tone' => 'critical',
                'confirm' => (string)__('Cancel the selected orders? This can\'t be undone.')],
        ];
    }

    public function getEmptyState(): array
    {
        return ['heading' => (string)__('No orders yet'), 'text' => (string)__('Orders show up here.')];
    }

    public function getRows(Query $query): array
    {
        $builder = $this->searchCriteriaBuilderFactory->create();
        if ($query->search !== '') {
            // Escape LIKE wildcards; the value itself is bound by the repository.
            $like = '%' . addcslashes($query->search, '%_\\') . '%';
            $builder->addFilters([
                $this->filterBuilder->setField('increment_id')->setConditionType('like')->setValue($like)->create(),
                $this->filterBuilder->setField('customer_email')->setConditionType('like')->setValue($like)->create(),
            ]);
        }
        // Column keys are not database fields: map every sortable key through an allowlist.
        $builder->addSortOrder($this->sortOrderBuilder
            ->setField(self::SORT_FIELDS[$query->sort] ?? 'created_at')
            ->setDirection($query->isAscending() ? SortOrder::SORT_ASC : SortOrder::SORT_DESC)
            ->create());
        $builder->setPageSize($query->pageSize)->setCurrentPage($query->page);
        $result = $this->orderRepository->getList($builder->create());

        return ['rows' => array_values(array_map([$this, 'toRow'], $result->getItems())), 'total' => $result->getTotalCount()];
    }

    // toRow(OrderInterface $order): array returns the row shape described below.
}

Query carries tab, search (trimmed, max 200 chars), sort (always a sortable column key or the default), direction (asc|desc), page (1-based), pageSize, filters (see "Scoped tables" below), plus getOffset(), isAscending() and getFilter($key). total must count the same rows the query can return: rows without an id are dropped, and when page is beyond the last page the service asks your provider once more for the last page (collection-based providers already clamp by themselves).

Register it (your module's etc/adminhtml/di.xml):

<type name="Mrx\Light\Model\IndexTable\ProviderPool">
    <arguments>
        <argument name="providers" xsi:type="array">
            <item name="orders" xsi:type="object">Mrx\Orders\Model\IndexTable\Orders</item>
        </argument>
    </arguments>
</type>

Another module can add columns and bulk actions to your table without touching it (ProviderExtensionInterface, section 17.4.4). A provider class can carry #[RequiresModule]: while that module is off the table does not exist (the block renders nothing, the data endpoint answers 404).

Provider codes are global: prefix them if in doubt (orders, products, customers, cms_pages, cms_blocks, categories, discounts are the obvious ones; the kit uses light_kit_pages).

Column keys: key, label, type (text|link|badge|money|date|thumbnail|number|tags, plus channel|language from section 16.2, which also take display text|badge), sortable, align (start|end|center; money and number default to end), primary (the cell becomes the row link; default is the first non-thumbnail column), hidden_label (screen-reader-only header, e.g. for a thumbnail column), width (CSS width).

Row shape: ['id' => 12, 'url' => $this->url->getUrl('light/orders/view', ['id' => 12]), 'cells' => [...]] (inject Magento\Backend\Model\UrlInterface; the URL carries the secret key). Cell value per type:

TypeValue
text'string' or ['text' => '#1001', 'subtext' => 'Sanne de Vries', 'wrap' => true]
link['label' => 'Sanne de Vries', 'url' => '...'], ['label' => 'x', 'url' => '...', 'external' => true] or ['label' => 'sanne@…', 'contact' => ['email' => ..., 'name' => ..., 'customerId' => 5]] (opens the contact modal)
badge['label' => 'Paid', 'tone' => 'success', 'dot' => true], ['label' => 'Not shipped', 'tone' => 'attention', 'progress' => 'incomplete'], ['label' => 'Paid', 'tone' => 'neutral', 'progress' => 'complete', 'progress_tone' => 'success'] (green icon on a grey badge), or a list of those
money12.5 (base currency), ['amount' => 12.5, 'currency' => 'EUR'], or a pre-formatted string
dateISO 8601 string (DateTimeInterface::ATOM); rendered relative
thumbnail'https://…/image.jpg' or ['url' => ..., 'alt' => ...]; empty → placeholder
numberint/float
tags['linen', 'sale'] (first 3 + "+N")

null/'' renders an em dash.

URLs in cells. Row url, link-cell url, thumbnail URLs and empty-state action URLs are filtered on the server (Mrx\Light\Model\SafeUrl) and again in the browser (dom.safeUrl): only http:, https:, mailto:, tel:, /path and #fragment survive; anything else (for example javascript: from a customer-entered field) is dropped and the cell renders as plain text. A link cell must be an array (['label' => ..., 'url' => ...]); a plain string renders as text, never as a link. Build admin URLs with UrlInterface::getUrl() and store-front URLs with the store's base URL, and don't put untrusted values into url without checking them yourself.

Page

<referenceContainer name="content">
    <block class="Mrx\Light\Block\IndexTable" name="mrx.orders.table">
        <arguments>
            <argument name="provider" xsi:type="string">orders</argument>
            <!-- optional arguments: see the list below -->
        </arguments>
    </block>
</referenceContainer>

Optional block arguments: page_size (number, default 50, max 250), url_state (boolean, default true), card (boolean, default true), selectable (boolean, default: true when there are bulk actions), filters (array of scalars, see below), default_tab (a tab key), default_sort (array key + direction, overrides the provider's default for this table), channel_filter (boolean, default true; false hides the channel select of a ChannelFilterAwareInterface provider, section 16.2).

The block renders nothing when the admin lacks the provider's ACL. Data comes from GET light/table/data/provider/<code>?tab=&q=&sort=&dir=&page=&limit=&filter[...]=&channel= → {success, rows, total, page, pageSize, tab, sort, direction, q, channel} (403 JSON when the ACL check fails, 404 for an unknown provider). The page URL keeps tab, q, sort, dir, page via history.replaceState, so reloads, shared links and back/forward work. Use one URL-state table per page (set url_state false on others).

Scoped tables (a customer's orders, the five most recent orders on Home). Implement Mrx\Light\Api\IndexTable\FilterableProviderInterface (extends ProviderInterface) and return the filter keys you accept from getFilterKeys(): array, e.g. ['customer_id']. Pass the values to the block: from layout XML (<argument name="filters" xsi:type="array"><item name="status" xsi:type="string">pending</item></argument>), or from the controller before rendering, since request values can't come from XML:

$page = $this->createPage(__('Sanne de Vries'));
$page->getLayout()->getBlock('mrx.customer.orders')?->setFilters(['customer_id' => $customerId]);
<block class="Mrx\Light\Block\IndexTable" name="mrx.customer.orders">
    <arguments>
        <argument name="provider" xsi:type="string">orders</argument>
        <argument name="page_size" xsi:type="number">5</argument>
        <argument name="url_state" xsi:type="boolean">false</argument>
        <argument name="card" xsi:type="boolean">false</argument>
    </arguments>
</block>

The filters travel in the data URL as filter[customer_id]=5; QueryFactory keeps only the keys your provider lists and only scalar values, and hands them to you as $query->filters / $query->getFilter('customer_id') (strings; cast them). They are not a security boundary: an admin who can open the provider can remove them, so never use a filter to hide rows the admin may not see (that is what getAclResource() is for). A provider that doesn't implement the interface never receives filters.

Behaviour: tabs, 150ms debounced search (Enter searches now, Esc clears), sortable headers (aria-sort, dates/money/numbers start descending), "1–50 of 1,234" with previous/next (focus moves to the other button or the table when the clicked one becomes disabled), row click (Cmd/Ctrl-click or middle-click opens a new tab; the primary cell is a real <a>), checkbox selection with select-all-on-page, a floating bulk bar ("3 selected" + actions), skeleton rows on first load (with the list's own column count), unless the page shipped its first rows (first_rows), a progress bar on reloads and, when a reload takes longer than 200 ms, skeleton rows in place of the old ones (Mrx_Light/js/loader), the provider's empty state on the default tab, "No results found / Try changing the filters or search term" on any other tab or search, "Clear search", and an error banner with "Try again". Changing tab, search, sort or page clears the selection, so a bulk action only ever runs on rows the merchant can see. Selection is per page; there is no "select all N" across pages yet. The root has aria-busy="true" while loading and fires mrx:table:loaded (detail.data) and mrx:table:bulk (detail: {action, ids, response}).

Columns: width, scrolling and choosing columns

Cells keep their natural width (white-space: nowrap except wrapped link subtext), and a table wider than its card scrolls sideways inside the card. From 768px up the checkbox and the primary column stay pinned while scrolling, with an edge shadow once scrolled. Do not squeeze columns with white-space: normal or fixed widths in feature CSS; let the table scroll.

Every th/td carries data-col="<column key>" (select for the checkbox), so feature CSS can target columns by key; never by position.

Admins choose columns with the "Edit columns" button next to the search (from 768px up). The choice is saved per admin user and table in admin_user.extra['mrx_table_columns'][<provider>] through light/table/columns (POST hidden = JSON list, or reset=1). Column options: hideable (default true; the primary column is never hideable) and default_hidden (hidden until the admin chooses otherwise). Phones always render every column so the feature modules' card layouts keep their cell order.

The Advanced switch lands on the stock screen that a Light page replaces (Redirect\Map::reverse(), exposed as config.advancedUrl); a Light page without a stock counterpart reloads in advanced mode. Switching to simple mode clears the "stay in stock screens" flag, so the reload redirects to the Light screen.

Rows can carry 'selectable' => false (for example a channel's menu root in the categories list): the row gets no checkbox, "select all" skips it and bulk actions never receive its id. Bulk controllers must still validate ids server-side.

A row with 'tone' => 'subdued' is a record that needs nothing more: its text, the primary cell included, turns --mrx-color-text-secondary, its badges keep their colours, and it stays clickable (class mrx-index-table__row--subdued). Orders uses it for a paid and shipped order. Any other tone is dropped.

Bulk actions

Action keys: key, label, route (+ params), tone (critical makes the button and the confirm button red), confirm (text; shows a confirmation modal first, with Cancel focused when tone is critical), resource (ACL; the action is left out for admins without it, so they don't meet a 403), mode and target:

modeWhat the table does
ajax (default)POSTs ids[] + form_key with fetch, expects the JSON contract from section 6, toasts the message and reloads; the rows that are still listed after the reload stay selected (and the bulk bar stays), rows that left the list (shipped on "Not shipped", deleted) drop out of the selection. When the JSON carries redirect (or url), it goes there instead: same tab by default, a new tab with 'target' => '_blank' (if the browser blocks that tab, the toast gets an "Open" button).
downloadSubmits a hidden <form method="post"> with ids[] + form_key to the route, in a new tab (target default _blank) or the same tab ('target' => '_self'). Use it for PDFs, CSV exports and printable pages: the controller returns the file or page itself, not JSON. The selection stays.

The kit's "Download IDs" action (light/kit/exportIds, a Raw result with Content-Disposition: attachment) is the reference for download.

'row_only' => true (since 0.3.0) keeps an action out of the bulk bar: only a row action that names it runs it, with the row's one id. The table config carries such actions under rowBulkActions, apart from bulkActions, so a page script that rearranges the bar's list never sees them; a list whose actions are all row-only gets no checkboxes. The kit's "Export this ID in a new tab" (kitext_tab, mode download, target _blank) is the reference.

Bar layout: buttons are 36px high and ordered with non-critical actions first and critical ones last. From 768px the bar shows at most three actions inline, and fewer when those would wrap onto a second row (the nav takes 240px, so this happens up to about 1000px); the rest move into a "More actions" (…) menu that opens upward (critical items red, last). A table with three actions or fewer shows them all, a critical one included. On screens up to 767px the bar spans the width, buttons grow to 44px touch targets, only the first action stays visible and the rest move into the menu. index-table.js decides this on every selection change and on resize (fitBulkBar()); an action whose menu item another module hid or disabled stays inline. Inline buttons carry data-action="<key>"; the mobile menu items are role="menuitem" with data-menu-action="<key>", so a desktop test locator such as .mrx-bulk-bar [data-action="delete"] or getByRole('button', {name: 'Delete'}) matches exactly one element. A mobile test opens the menu with getByRole('button', {name: 'More actions'}) and then clicks getByRole('menuitem', {name: …}). While the bar is visible the body gets mrx-has-bulk-bar (96px bottom padding), so the last rows can scroll above it.

A bulk route may ask before it acts: it answers 409 with confirm (title, message, label, param) and writes nothing. index-table.js (postBulk()) shows that question with modal.confirm and, on yes, posts the same ids[] again with param (default confirmed) set to 1; on no, the route's message shows as an error toast. Products > Categories uses it for "Show in menu" and "Hide from menu" when a language has its own setting.

Bulk action controller

With mode ajax the table POSTs ids[] and form_key to the action's route and expects the JSON contract from section 6:

class MassPaid extends Action implements HttpPostActionInterface
{
    public const ADMIN_RESOURCE = 'Magento_Sales::invoice';

    public function execute(): Json
    {
        $ids = array_values(array_filter(array_map('intval', (array)$this->getRequest()->getParam('ids', []))));
        // ... do the work through service contracts ...
        return $this->resultFactory->create(ResultFactory::TYPE_JSON)
            ->setData(['success' => true, 'message' => (string)__('%1 orders marked as paid', count($ids))]);
    }
}

The message becomes the toast; the table reloads and keeps the rows that are still listed selected. Read ids defensively (is_array, cast, drop zeros): the request is user input.

Row actions

A provider, or a ProviderExtensionInterface another module adds, may also implement Mrx\Light\Api\IndexTable\RowActionsInterface. Each row with an action that applies gets a "More actions" button (.mrx-index-table__row-actions, icon more, aria-label "More actions for {primary cell text}") that opens a menu with the row's actions:

public function getRowActions(): array
{
    return [
        ['key' => 'handled', 'label' => (string)__('Mark as handled'), 'bulk' => 'handled', 'visible_when' => ['status' => 'new']],
    ];
}

bulk names one of the table's bulk actions: the row posts to that action's route with ids[] set to the row's id, through the same code path (confirm, the toast, the reload), so there is no second controller. visible_when looks each key up in the row's meta (which getRows() may add, 'meta' => ['status' => 'new'], and which stays on the server), then in its cells, and compares as text; a row without the key doesn't get the action. Rows with 'selectable' => false get no row actions, like bulk actions. tone: 'critical' makes the item red. A row action whose bulk is unknown, or whose bulk action's resource the admin lacks, is left out; an extension's row action keys start with its extension key, as its columns do; a getRowActions() that throws gives no row actions and is logged, and the table still renders. Filter and sort extensions are not part of the 0.1.0 beta. How to add them: Light lists.


8. Navigation: items, children and badges

Items live in Mrx\Light\Model\Navigation\Config (argument items, declared in Mrx_Light/etc/adminhtml/di.xml). Keys: home, orders, products (child categories), customers, content (children pages, blocks, menu; Mrx_Content adds redirects, "Redirects"), discounts, analytics (Mrx_Home, "Analytics", gated on Magento_Reports), apps (Mrx_Apps, position bottom, sort order 90, with one bottom item per pinned app at 91 to 99), settings (position bottom, 100). Page modules add children: reviews under products (Mrx_Catalog), returns under orders (Mrx_Returns).

Item keys: label (translate="true"; translated when the nav is built, see "di.xml strings" in section 13), route, params, fallback_route + fallback_params (the stock screen used while route's controller does not exist yet), resource (ACL; hidden when not allowed, and also hidden when the controller behind the resolved route declares an ADMIN_RESOURCE the admin lacks, so the nav never links to "access denied"; a parent the user can't open still shows when a child is allowed), sort_order, icon, parent (key of the parent), position (main|bottom), shortcut ("g o"; taken: g h Home, g o Orders, g p Products, g c Customers, g w Content, g d Discounts, g a Analytics, g s Settings; the "?" dialog lists them), match (full-action-name prefixes that make the item active, e.g. light_orders_, sales_order_), disabled (true hides it), module (a module name or a list; the item is left out while any of them is disabled, section 17.2), url (a ready admin URL, used as it is instead of route and fallback_route; meant for runtime items, see "Items computed at runtime" in section 17.4.1), advanced (true: the link opens the stock screen in the advanced view, its URL gets mrx_stock=1, and the item shows the external icon with the class mrx-nav__item-end and a visually hidden "Opens in the advanced view"). Items that a module can only know at runtime come from an ItemProviderInterface in the Config argument providers (section 17.4.1).

Phone drawer. Up to 767px the menu is a drawer, and a top-level item with children is a disclosure there: its button (.mrx-nav__toggle, aria-expanded, aria-controls pointing at #mrx-nav-sub-{key}) takes the place of the link, and a tap expands the item's list in place instead of leaving the page. The list starts with the item's own page under its own label (.mrx-nav__sub-item--own), then the children, so the whole section is one tap away without a request. The drawer opens with the section of the current page expanded, opening another section collapses the first (one at a time keeps the top level in view on a short screen), and the item's count stays on its button. Items without children behave as before. Every section renders its children (each with its own count) and CSS decides what shows: the sidebar from 768px shows the active section's list only, and without script the phone drawer does the same and keeps the link. Mrx_Light/js/shell adds .mrx-nav--disclosure to #mrx-nav when it takes over, sets .is-open on the section and aria-expanded on its button, and keeps focus on the button; the height animation is off under prefers-reduced-motion. nav-badges.js updates the button's count together with the link's, since both carry data-mrx-badge. A browser test of the phone drawer taps the button (getByRole('button', {name: 'Orders'})) and then the list's link; a desktop test still finds the section's link and its children under the active item, and a locator that lists .mrx-nav__sub-link sees every section's children, so scope it with .mrx-nav__item.is-active.

Built-in fallbacks: home → adminhtml/dashboard/index, orders → sales/order/index, products → catalog/product/index, categories and menu → catalog/category/index, customers → customer/index/index, content and pages → cms/page/index, blocks → cms/block/index, discounts → sales_rule/promo_quote/index, settings → adminhtml/system_config/index. Mrx\Light\Model\Navigation\Builder::getUrl('orders') returns the resolved (Light or fallback) URL of an item, or null when the admin can't open it; the top bar, "Go to", shortcuts and config.nav all use it.

Add a child from your module (merged by DI):

<type name="Mrx\Light\Model\Navigation\Config">
    <arguments>
        <argument name="items" xsi:type="array">
            <item name="drafts" xsi:type="array">
                <item name="label" xsi:type="string" translate="true">Drafts</item>
                <item name="route" xsi:type="string">light/orders/drafts</item>
                <item name="resource" xsi:type="string">Magento_Sales::create</item>
                <item name="parent" xsi:type="string">orders</item>
                <item name="sort_order" xsi:type="number">10</item>
                <item name="match" xsi:type="array">
                    <item name="light_orders_drafts" xsi:type="string">light_orders_drafts</item>
                </item>
            </item>
        </argument>
    </arguments>
</type>

The longest matching prefix wins, so light_orders_drafts beats light_orders_. Children show only while their parent is active.

match also decides which section a stock screen without a Light replacement belongs to (the nav highlight and the advanced-view banner, section 9). Besides each section's own routes, Mrx_Light maps: Home reports_ (sales reports, statistics); Orders reports_report_shopcart_ (abandoned carts); Products search_term_, search_synonyms_, reports_report_product_, reports_report_review_; Customers loginascustomer_log_, reports_report_customer_; Content media_gallery_, adminhtml_widget_instance_, pagebuilder_template_; Settings adminhtml_email_template_, theme_design_config_, adminhtml_system_design_, adminhtml_system_currency (rates and symbols), adminhtml_integration_, indexer_, bulk_, adminhtml_system_variable_. A stock screen no prefix claims falls back to Home.

Counts (spec 12): implement Mrx\Light\Api\Navigation\BadgeSourceInterface::getBadgeValue(): ?Badge and register it in the BadgePool argument providers (catalogue pool nav_badge_providers, adminhtml), under a provider code that starts with your module's prefix. Returns does it like this:

<type name="Mrx\Light\Model\Navigation\BadgePool">
    <arguments>
        <argument name="providers" xsi:type="array">
            <item name="returns_needing_action" xsi:type="array">
                <item name="item" xsi:type="string">returns</item>
                <item name="provider" xsi:type="object">Mrx\Returns\Model\Navigation\ReturnsNeedingAction\Proxy</item>
                <item name="resource" xsi:type="string">MageOS_RMA::rma_manage</item>
                <item name="module" xsi:type="string">MageOS_RMA</item>
            </item>
        </argument>
    </arguments>
</type>

Keys: item (the nav key the count shows on), provider (a BadgeSourceInterface; register its \Proxy, because DI builds every provider with the pool on every simple-mode page), resource (ACL; without it the provider counts only for admins allowed the item's own resource), module (section 17.2), rollup (a top item outside the active section sums its children's roll-up counts), ttl (seconds, default 60, at least 10) and sort_order. Return null at 0. new Badge(5, label: (string)__('%1 returns to handle', 5)) has the tone attention; use critical only when something is broken or blocks a sale (Badge::TONES). label is what a screen reader hears. Several providers on one item add up into one pill with the most urgent tone, and getDisplay() shows 99+ above 99. The old BadgeProviderInterface::getBadge(): ?string and the badges argument still work, as the provider legacy_<item>; a trailing + means "more than". Orders counts through its provider orders_not_shipped and sets its own old badges/orders entry to null.

Markup inside the nav link: <span class="mrx-nav__badge mrx-nav__badge--attention" aria-hidden="true">5</span><span class="mrx-visually-hidden" data-mrx-badge-label>5 returns to handle</span>. Every link that can show a count carries data-mrx-badge="<item>", and a closed parent with roll-up also data-mrx-badge-rollup. mrx-nav__badge--critical sets --mrx-color-nav-badge-bg and --mrx-color-nav-badge-text from the critical tone; mrx-nav__badge--attention sets nothing. Themes set those two tokens at their root, never background or color on .mrx-nav__badge (themes.md section 8).

Rendering never runs a provider: the nav reads the cache type mrx_light_badges (one entry per provider and admin locale). What is stale or missing is fetched by one background request after the page loads, and the page then dispatches mrx:badges with detail: {items}; that request's JSON is internal. After your module changes what it counts, call Mrx\Light\Api\Navigation\BadgeCacheInterface::invalidate('<item>'), which works in every area (observers, REST, cron). In the admin, BadgeReaderInterface::get('<item>') gives the current count, so pins, "Go to" and Home show the same number as the nav. The nav as a whole is never cached, so secret-key URLs stay current. How to add a counter: A nav item with a counter.


9. Simple-mode redirects from stock screens

In simple mode a GET request for a mapped stock page redirects to its Light screen, but only when the Light controller class exists (so an entry can be registered before the screen is finished) and the admin passes its ADMIN_RESOURCE (someone who may open the stock screen but not the Light one stays on the stock screen), never for AJAX, never for non-GET, and never when the request carries mrx_stock=1. Mapped parameter values are URL-encoded into the redirect.

<type name="Mrx\Light\Model\Redirect\Map">
    <arguments>
        <argument name="redirects" xsi:type="array">
            <item name="sales_order_view" xsi:type="array">
                <item name="route" xsi:type="string">light/orders/view</item>
                <item name="params" xsi:type="array">
                    <item name="order_id" xsi:type="string">id</item>   <!-- stock param => Light param -->
                </item>
            </item>
            <item name="catalog_product_new" xsi:type="array">
                <item name="route" xsi:type="string">light/products/new</item>
                <item name="condition" xsi:type="object">Mrx\Catalog\Model\Redirect\SimpleProductsOnly</item>
            </item>
        </argument>
    </arguments>
</type>

The key is the stock full action name (route id _ controller _ action, e.g. sales_order_index, catalog_product_edit, customer_index_edit, cms_page_edit, sales_rule_promo_quote_index, adminhtml_dashboard_index, adminhtml_system_config_edit). Keys match in any case, because the action keeps the URL's spelling (editWebsite); write them in lower case. condition is optional: a Mrx\Light\Api\Redirect\ConditionInterface (shouldRedirect(RequestInterface $request): bool). Mrx\Light\Model\Redirect\Condition\HasParam (argument param, used through a virtualType) redirects only when that request parameter is set and not 0, for a stock action that doubles as the "new" form without its id.

query is optional: fixed query parameters for the Light URL (<item name="query" xsi:type="array"><item name="tab" xsi:type="string">low_stock</item></item>), for a Light list whose tab or filter lives in the query string. one_way (boolean) keeps an entry out of Map::reverse(), so the Advanced switch on the Light screen never lands on it; use it whenever several stock screens lead to one Light screen and only one of them is its stock twin. Map::toUrlParams($target) turns a resolve() result (route, params, query) into getUrl() parameters.

resolver is optional too: a Mrx\Light\Api\Redirect\ParamsResolverInterface (resolve(RequestInterface $request): ?array) for a Light screen that needs a parameter the stock URL doesn't carry. It returns the Light parameters (merged over params), or null to keep the stock screen (the record doesn't exist). An entry with a resolver is one-way: Map::reverse() skips it, so the mode switch never lands on it. Built in (registered by Mrx_Light, pointing at Mrx_Orders and Mrx_Settings screens, so they only act when those controllers exist):

Stock screenLight screenHow
sales_order_invoice_view, sales_invoice_view, adminhtml_order_shipment_view, sales_shipment_view, sales_creditmemo_view, sales_order_creditmemo_viewlight/orders/view/id/{order}resolver Redirect\Resolver\SalesDocumentOrder (invoice, shipment or credit memo repository → order id)
adminhtml_system_store_editwebsitelight/settings/channel/id/{website}website_id → id, condition WebsiteIdGiven (newWebsite forwards here without an id and stays stock)
adminhtml_system_store_editgroup, adminhtml_system_store_editstorelight/settings/channel/id/{website}resolver Redirect\Resolver\StoreScopeWebsite (group or store view → website id)

"Open in advanced view" links: Mrx\Light\Model\Redirect\AdvancedUrl::get('sales/order/view', ['order_id' => 12]) (or add '_query' => ['mrx_stock' => 1] to any getUrl() / header action). Once a merchant opens the advanced view, the stock screens they reach from there (form posts, saves, grid links) stay stock until they open any Light page again; that stickiness lives in the admin session.

Hidden stock screens. Stock screens a small shop never needs open the nearest Light screen in simple mode (one-way entries registered by Mrx_Light). They stay reachable through the advanced view (mrx_stock=1, advanced mode), where the banner leads back to that Light screen; no palette entry or Apps card links to them.

Stock screensLight screen
Shipments grid (sales_shipment_index), order statuses (sales_order_status_index/new/edit/assign), abandoned carts (reports_report_shopcart_abandoned)Orders
Customers now online (customer_online_index)Customers
Newsletter templates, queue and problem reports (newsletter_template_index/new/edit, newsletter_queue_index/edit, newsletter_problem_index)Customers, Subscribers
Search terms and synonyms (search_term_index/new/edit, search_synonyms_index/new/edit), attribute sets (catalog_product_set_index/add/edit), export and import history (adminhtml_export_index, adminhtml_history_index)Products
Low-stock report (reports_report_product_lowstock)Products, tab Low stock (?tab=low_stock)
Ratings (review_rating_index/new/edit)Reviews
Widgets (adminhtml_widget_instance_index/new/edit), Page Builder templates (pagebuilder_template_index)Pages
Tax rate import/export (tax_rate_importexport)Settings > Taxes
Currency rates and symbols (adminhtml_system_currency_index, adminhtml_system_currencysymbol_index)Settings > Business details
Index management (indexer_indexer_list), bulk actions log (bulk_index_index), custom variables (adminhtml_system_variable_index/new/edit)Settings
Report statistics (reports_report_statistics_index), search terms report (search_term_report)Analytics

Avoid redirect loops. Once sales_order_view maps to light/orders/view, any Light controller or link that sends the merchant to sales/order/view in simple mode bounces straight back to the Light screen. Every "Open in advanced view" link, every fallback to a stock screen and every stock URL you redirect to from a Light controller must go through AdvancedUrl::get() (or carry _query mrx_stock=1).

Search results and "Actions" deliberately use stock URLs (sales/order/view, catalog/product/edit, customer/index/edit, cms/page/edit, catalog/product/new, sales_rule/promo_quote/new, ...): your redirect entry is what sends them to your screen. SearchService adds mrx_light=1 (Mrx\Light\Model\Redirect\LightUrl::mark()) to every stock admin URL a search group returns, unless it already carries mrx_stock (Light URLs stay clean; opening them ends the sticky view anyway). RedirectToLight treats that marker as "the merchant asked for the Light screen": it ends the sticky advanced view and redirects even when another tab opened a stock screen with mrx_stock=1 a moment ago. Use LightUrl::mark() for any other shell link to a stock URL that must land on the Light screen; a feature module never needs to add the marker itself.

Verified behaviour: mapped + controller present → redirect with mapped params; mapped but no controller → stock page; AJAX → stock; ?mrx_stock=1 → stock and sticky; ?mrx_light=1 → sticky flag cleared, then the normal redirect; a Light page visit clears the sticky flag.

Advanced-view links: one wording. Every link into a stock screen says where it goes, in one of two forms, wherever it sits (header button, More actions, banner, card, empty state, palette). Never "advanced editor", never "(advanced)":

Link opensWordingExample
The record or list on screen, as a stock screenOpen in advanced viewalways this exact label
A stock screen that does one thing{Verb} {noun} in the advanced view"Create order in the advanced view", "Import products in the advanced view", "Edit variants in the advanced view", body text "Change it in the advanced view."

Replace older variants on sight: "Open in advanced editor" → "Open in advanced view"; "Create order (advanced)" → "Create order in the advanced view"; "Import (advanced)" → "Import products in the advanced view"; "Edit this discount in the advanced editor" → "Edit this discount in the advanced view". The Dutch forms are "Openen in geavanceerde weergave" and "{Werkwoord} {ding} in de geavanceerde weergave".

Palette "Go to" destinations that open a stock screen (their params carry _query mrx_stock=1) always get the subtitle "Advanced view" (Destinations sets it, whatever the di.xml subtitle says), e.g. "All settings" and "Import products".

Advanced-view banner. Every stock page rendered in simple mode starts with an info banner titled "You're in the advanced view", a line "Every setting is here, also the ones the simple screens leave out." and a "Back to {screen}" button (block mrx.advanced.banner in main.top, Mrx\Light\ViewModel\AdvancedView, marker [data-mrx-advanced-banner]). The link goes to, in this order:

  1. the Light screen this stock page maps to (Redirect\Map::resolve(), with the entity id);
  2. the Light screen in the referer;
  3. the Light page that opened this stock flow: when a request carries mrx_stock=1 and its referer is a Light page, StockSession remembers that page for every stock screen of the same route and controller (sales_order_create_*), so a reload or a form post inside "Create order" still leads back to the customer it was started from. Opening any Light page forgets it, like the sticky flag;
  4. a back-link provider: the first Mrx\Light\Api\AdvancedView\BackLinkProviderInterface in the AdvancedView argument providers (by sort_order) that returns a link. It may also replace the line under the title (section 17.4.7);
  5. a Light page that is not in the navigation but owns the stock screen: AdvancedView argument sections (label, route, params, match prefixes; skipped when the route's controller is missing or refuses the admin). Built in: email templates → Settings > Customer emails; design configuration, themes and design schedule → Business details (#branding); currency rates and symbols → Business details. A section without match claims no stock screen; it only names its page when a redirect (step 1) lands there, as the built-in Taxes entry does for tax rules, rates and the rate import/export ("Back to Taxes", not "Back to Settings");
  6. the section the stock page belongs to (Navigation\Builder::findItem() on the full action name, see the match list in section 8, e.g. order create → Orders);
  7. Home.

Steps 2 and 3 give way to a back-link provider whose link goes to the same Light page as that referer or origin (the same full action name, LightPage::parse()): such a link names the spot on the page, as Back to Apps opens the app's row (#app-<key>) after a click on that row.

When that target is one record (the mapped params or the Light URL carry id, e.g. light/orders/view/id/12) the link says "Back to simple view"; a list or section target keeps "Back to {section}" ("Back to Customers", "Back to Customer emails"). Opening that Light screen ends the sticky advanced view. Builder::findItem(string $fullActionName): ?array returns the allowed nav item (child first) for any full action name.

Only in the advanced view. A simple-mode page that leaves a setting to the stock screen shows the Mrx\Light\Block\AdvancedHint block ("Only available in the advanced view", with a link that opens that screen through AdvancedUrlInterface). It renders nothing without a route, or when the admin can't open it. The kit page shows it.


10. Search and the command palette

GET light/search/index?q= returns {success, query, groups: [{code, label, items: [{title, subtitle, url, icon, thumbnail, badge: {label, tone}|null, channel: {label, tone}|null}]}]}. Items may return store (store id) or channel (website id); on a multi-channel install that becomes the channel badge (section 16.6). Item url and thumbnail are filtered to safe schemes (as in section 7); an item whose URL is dropped is left out. Groups are Mrx\Light\Api\Search\GroupInterface objects in Mrx\Light\Model\Search\GroupPool (argument groups), sorted by getSortOrder(); each group's getAclResource() is checked server-side before search() runs (empty string = any admin), and a failing group is logged and skipped. Built in: orders (10, sales_order_grid: increment id, customer name, email; a numeric query ranks the exact order number first, so "17" finds #000000017 and #18000000017 before #000000117 and #000000179; Mrx_Orders replaces this group with its own), products (20, name/SKU via ProductRepositoryInterface, with thumbnails; "Not Visible Individually" variants are left out, and exact SKU, name-prefix and word-start matches come first), customers (30, customer_grid_flat: name, email, phone; Mrx_Customers replaces this group with one that reads customer_entity like its list, because the grid index lags behind new accounts, and links to light/customers/view), pages (40, title/URL key), destinations (50, "Go to": every allowed nav item plus the settings destinations; items that share a URL, like Content and Pages, show once), actions (60; entries that open a stock screen say so in the label, e.g. "Create order in the advanced view"; see "Advanced-view links" in section 9). "Go to" and "Actions" entries are left out when the admin lacks their resource or the ADMIN_RESOURCE of the controller behind their route (Redirect\Map::isRouteAllowed()), so a mismatched resource in a module's di.xml can't offer a link that answers "access denied". With an empty query only groups that return suggestions show (Go to and Actions); results are capped at 5 per group (20 for suggestions). Exact matches go first, because Enter opens the first result: SearchService ranks an item 2 when its title equals the query ("#000000016", "Emma Smit") and 1 when one ·-separated part of its subtitle does ("emma.smit@example.com · Utrecht"), ignoring case and a leading "#"; items move up within their group and groups move up by their best item, otherwise the group order stays. So an exact email opens the customer, not their latest order, and an exact order number opens that order. Keep an identifying value (email, order number) as the title or as its own subtitle part to benefit.

Replace a built-in group by registering your own object under the same key (e.g. products), or add a group:

class Discounts implements GroupInterface
{
    public function getLabel(): string { return (string)__('Discounts'); }
    public function getAclResource(): string { return 'Magento_SalesRule::quote'; }
    public function getSortOrder(): int { return 45; }
    public function search(string $query, int $limit): array
    {
        if ($query === '') {
            return [];
        }
        // ... return [['title' => 'SUMMER10', 'subtitle' => '10% off', 'url' => $url, 'icon' => 'discounts',
        //              'badge' => ['label' => 'Active', 'tone' => 'success']]];
    }
}

Add "Go to" destinations or "Actions" without code:

<type name="Mrx\Light\Model\Search\Group\Destinations">
    <arguments>
        <argument name="destinations" xsi:type="array">
            <item name="settings_taxes" xsi:type="array">
                <item name="label" xsi:type="string" translate="true">Taxes and duties</item>
                <item name="subtitle" xsi:type="string" translate="true">Settings</item>
                <item name="route" xsi:type="string">light/settings/taxes</item>
                <item name="resource" xsi:type="string">Magento_Tax::config_tax</item>
                <item name="keywords" xsi:type="string">vat btw tax</item>
                <item name="icon" xsi:type="string">settings</item>
                <item name="sort_order" xsi:type="number">45</item>
            </item>
        </argument>
    </arguments>
</type>
<type name="Mrx\Light\Model\Search\Group\Actions">
    <arguments>
        <argument name="actions" xsi:type="array">
            <item name="add_product" xsi:type="array">
                <item name="route" xsi:type="string">light/products/new</item>   <!-- merged into the built-in entry -->
            </item>
        </argument>
    </arguments>
</type>

Both take module (a module name or a list; the entry is left out while any of them is disabled, section 17.2) and sort_order: entries are listed by sort_order, then by label, never in module load order. The built-in ones use 10 to 600; an entry without sort_order counts as 1000, after them. "Actions" entries also take subtitle (translated like the label). A palette group class can carry #[RequiresModule].

Commands. An "Actions" entry with command (an AMD module id such as Mrx_Light/js/cache) runs that module's run() instead of opening a page: the palette closes at once (focus back on what opened it) and the module takes over (a confirm modal, a request, a toast). Its route then only lends the ACL of the controller behind it (Redirect\Map::isRouteAllowed()), next to resource. A search result with command carries url: ''; SearchService drops commands that are not Vendor_Module/js/.... Built in: clear_cache ("Clear cache", Magento_Backend::flush_magento_cache), which asks first and then posts mode=all to light/cache/refresh.

DI merges items with the same key, so an override keeps the built-in keys you don't repeat (the built-in add_product also has params set=4, type=simple, which would then be appended to your route; a redirect entry for catalog_product_new is usually the cleaner option). The Settings agent should override the built-in stock destinations (business_details, payment_methods, shipping_methods, tax_settings, sales_emails, sender_addresses, users, account, all_settings) with its Light routes the same way. Matching is case-insensitive on every word of the query against label, subtitle and keywords. A destination without a label shows no result of its own: when its URL is a nav item's, its keywords go to that item.

Destinations and actions take advanced too (true: the entry opens the stock screen in the advanced view and reads "Advanced view"). A "Go to" entry for a nav item that counts shows that count, read through BadgeReaderInterface (section 8).


11. Contact customer (email from anywhere)

UI: data-mrx-contact or contactCustomer.open(...) (section 5). The modal asks before Esc, the backdrop or the close button throws away a typed message. Server: POST light/customer/message with email, subject, message, optional name, customer_id, order_id, send_copy (1/0); ACL Mrx_Light::contact_customer (a new resource under Mrx_Light::light), plus Magento_Sales::sales_order when order_id is sent and Magento_Customer::manage when customer_id is sent (403 otherwise). With an order or customer, email must be empty or equal (case-insensitive) to the order's or customer's email, and an order and customer sent together must belong together (422 otherwise). A bare email without IDs is allowed and is logged without links. PHP: Mrx\Light\Model\CustomerMessage\Sender::send(new Message($email, $subject, $body, $name, $customerId, $orderId, $sendCopy)): int (log row id; 0 when the mail went out but the log row or order note could not be written, which is logged and still reported as sent so nobody sends it twice; throws LocalizedException with a merchant-readable message before sending).

What it does: validates (email, subject ≤ 255, message ≤ 20,000), resolves the store (order store, else customer store, else default), sends template mrx_light_contact_template (config mrx_light/contact/template; file Mrx_Light::view/frontend/email/customer_message.html, store header/footer, an "about your order #…" line when sent from an order, then the message exactly as typed, HTML-escaped with line breaks; no automatic greeting) from identity mrx_light/contact/identity (default general, i.e. trans_email/ident_general), BCCs the sending admin when asked, writes a row to mrx_customer_message (entity_id, customer_id, customer_email, order_id, admin_user_id, subject, body, copy_sent, store_id, sender_name, sender_email, created_at; store_id and the sender are the store and the "From" address the mail went out with, so a timeline shows the sender as it was even after the store's email changes; rows from before these columns have them NULL, and a timeline falls back to today's sender for those) and, with an order, adds an order comment "Email sent to customer: " (is_customer_notified=1, is_visible_on_front=0) through OrderStatusHistoryRepositoryInterface (no extra customer email). The mail renders in the locale of the store it is sent from; the template's own lines and the default subject "A message from %1" ship in i18n/nl_NL.csv, de_DE.csv and fr_FR.csv. A module that prefills subject must render it in the customer's store language itself (store emulation), as Mrx_Orders does. Every email the admin renders for a store (this modal, the shipment and order mails sent from the Light order screens, stock resend buttons) takes its static and media URLs (email logo, email-fonts.css, {{view url}}) from that store's domain, like a storefront mail: Mrx\Light\Plugin\Email\StoreAssetUrls wraps Magento\Email\Model\Template::processTemplate() in Mrx\Light\Model\Email\StoreAssetScope::run($storeId, ...), which resets the asset repository's cached base URLs and makes Magento\Framework\Url::getBaseUrl() answer static and media URLs for that store (adminhtml only). A module that sends mail through TransportBuilder or the stock sales senders gets this without doing anything. Customer and order timelines read the log with Mrx\Light\Model\CustomerMessage\Log::getByCustomer(?int $customerId, string $email = '', int $limit = 50) and getByOrder(int $orderId, int $limit = 50) (newest first).


12. Modes, shell and layout handles

HandleAdded byContents
mrx_baseobserver, every admin page after a complete sign-in (signed in and past the second factor where Magento_TwoFactorAuth is on), both modes, never a page built on the sign-in layout admin-login (sign-in, password and two-factor screens)light.css (after theme CSS), #mrx-config JSON, the shell JS, the critical-message banner; Magento_AdminNotification's message bar, pop-ups and toolbar removed
mrx_simpleobserver, simple modebody class mrx-simple, browser title {page title} · {store name} instead of the stock menu path and "Magento Admin", the advanced-view banner on stock pages (section 9), skip link to #anchor-content, top bar + Light nav in menu.wrapper (div.mrx-shell), stock menu hidden (display="false", the block still exists for setActiveMenu), header user/search removed and the stock header container hidden (a module's block there, such as Magento_AdminAnalytics' tracking scripts, still runs but draws no band), the notification bell in the top bar, page title moved into the content, footer removed, light restyle of stock screens
mrx_advancedobserver, advanced modebody class mrx-advanced, "Switch to simple mode" in the stock user menu, the notification bell beside it
mrx_pageAbstractPagebody class mrx-page, device-width viewport, page header block, stock page title and button bar hidden

Top bar on phones. Up to 480px the bar shows icons only: the search field becomes an icon button (still data-mrx-palette-open, aria-label "Search") that opens the palette, the store switcher drops its chevron, and every control is a 40px target (44px on touch) with --mrx-space-2 gaps. From 481px the full search field is back.

Palette on phones. At (max-width: 480px), (hover: none) and (pointer: coarse) (the same query in Mrx_Light/js/command-palette and light.css) the palette is a full-screen panel anchored to the top: no rounded corners, no grab handle, a Close button instead of the Esc chip, and no keyboard footer. The script keeps the panel's top and height equal to window.visualViewport (offsetTop, height; once per frame on its resize and scroll), so when the on-screen keyboard opens or closes the search row stays put and only the results list, which scrolls on its own (overscroll-behavior: contain), grows or shrinks. Without visualViewport it is 100dvh high (100vh where dvh is unknown). It fades in; Close, Escape and a result close it at once. Tablets with touch get the same panel. Desktop keeps the centred dialog with its hints.

Notification bell. A bell with a count badge (--mrx-tone-critical-*), in the top bar's icon row in simple mode and beside the stock user menu in advanced mode, while Magento_AdminNotification is on (shell/notifications.phtml, ViewModel\Notifications, Mrx_Light/js/notifications). The count is the system messages Magento shows now (indexers invalid, cache invalidated, ...; ACL Magento_AdminNotification::show_list) plus the unread notifications (show_toolbar). The button (aria-haspopup="dialog", aria-expanded, aria-label "Notifications, 3 new") opens a non-modal dialog panel that takes focus: "System messages" with Magento's text and links, then up to five notifications with title, text, date, "Read more" when it has a link and "Mark as read" (mark_as_read; posts to adminhtml/notification/ajaxMarkAsRead, removes the item, lowers the count, says "Marked as read" through a status region and keeps focus in the panel), and "See all notifications" (adminhtml/notification/index). Tab walks the links and buttons; Escape and the close button return focus to the bell; a click outside or focus leaving the panel closes it. On phones the panel sits under the bar inside the screen's gutters and the bell is a 44px target on touch. A critical item also shows one compact critical banner in main.top whose "View messages" button opens the bell; in simple mode, while the top bar's cache menu shows, the banner leaves Magento's cache-invalidated message to that menu (mrx_simple passes ViewModel\CacheNotice to the banner as cache_notice). With nothing to show, the panel says "You're all caught up." Without Magento_AdminNotification there is no bell.

Cache menu. A small "Cache" button with a count badge in the top bar (small button scale, 28px; on phones an icon, 44px on touch), next to the bell's "cache invalidated" system message. It renders only while at least one enabled cache type is invalidated, and only for admins with Magento_Backend::cache plus refresh_cache_type or flush_magento_cache; with nothing outdated there is no control at all. It opens a popover menu (shell/cache-notice.phtml, ViewModel\CacheNotice, Mrx_Light/js/cache; arrow keys and Escape through Mrx_Light/js/popover): "Clear all caches" on top (flush_magento_cache, no extra question), "Refresh outdated (N)" only when N is 2 or more (a single outdated type is already its own row), then an "Outdated" group with one row per enabled and invalidated type (refresh_cache_type). A row closes the menu with focus back on the button, posts, shows a toast and updates the count and the rows from the answer; once nothing is outdated the control leaves the top bar and focus moves to the search button. Clearing everything at any time is the palette's "Clear cache" command (section above). Labels are Magento's own from cache.xml, or the friendlier ones in the labels argument of Model\Cache\CacheStatus (adminhtml di.xml: full_page "Page cache", block_html "Page blocks", eav "Attributes").

POST light/cache/refresh (ADMIN_RESOURCE Magento_Backend::cache, form key checked) takes mode: invalidated (default; cleans each invalidated type like Cache Management's "Refresh"), types with types[] codes (each must be declared in TypeListInterface::getTypes(), enabled and invalidated; otherwise it answers 422 with "This cache doesn't exist.", "This cache is switched off." or "This cache is already up to date." and nothing is cleaned), both needing refresh_cache_type, or all ("Flush Magento Cache": cleans every cache frontend and dispatches adminhtml_cache_flush_system, so Magento_PageCache and Magento_CacheInvalidate purge the full page cache and Varnish; needs flush_magento_cache). Refreshing full_page purges Varnish too (its type dispatches adminhtml_cache_refresh_type). It answers {success, mode, refreshed: [codes], invalidated: [codes still outdated], message}; 403 without the mode's resource, 422 for another mode or bad types. PHP: Model\Cache\CacheStatus::getTypes() / getInvalidated() / getLabel() and Model\Cache\CacheRefresher::refreshInvalidated() / refreshTypes() / clearAll().

Read the mode through Mrx\Light\Api\ModeInterface (admin area only; its preference is Mrx\Light\Model\ModeState): get(): string (ModeInterface::SIMPLE or ModeInterface::ADVANCED), isSimple(): bool, isStockView(): bool (a stock screen shown in the advanced view on purpose, through the mrx_stock parameter) and showsLight(): bool (a stock screen that a Light page replaces sends the admin to that page). In the browser: config.get('mode'), config.get('stockView') and config.get('apiVersion') (the Light extension API version, ExtensionApi::VERSION). Link to a stock screen in the advanced view with Mrx\Light\Api\AdvancedUrlInterface::get($route, $params), which adds mrx_stock=1. The mode is stored per admin in admin_user.extra['mrx_mode'] (default simple); Mrx\Light\Model\Mode and POST light/mode/set are internal, so a module never calls them. Contract (spec 8.1): the handles mrx_simple and mrx_advanced (Light), mrx_settings_page (Settings) and light_settings_app_{code} (schema settings pages), and the markup hooks body.mrx-simple, body.mrx-advanced, body.mrx-login, html[data-mrx-theme] and html[data-mrx-scheme]. The handles mrx_base and mrx_page, the body classes mrx-page and mrx-page--narrow|--full, and Magento's full-action-name class (light-orders-index) work, but they are tier 2: retest them after every minor.

Stock screens in simple mode keep working: the fixed action bar and sticky grid headers are offset for the top bar and nav (the sticky header lines up with the grid card), wide stock grids scroll inside their card instead of the page, .action-primary is near-black, the dashboard store switcher is hidden; other scope switchers stay (they are needed for store-view values). Stock screens still use M137's pill buttons and put the title and the button bar on two rows. The session messages a stock screen shows after a redirect and the global notices take the banner look (section 4) in simple mode, in their tone (success, critical for errors, warning, info for notices); their links keep working, and the selectors go through :where(), so a theme's own message rules win. Magento_AdminNotification's system messages and notifications are in the notification bell in both modes (below); its yellow "System Messages" bar, its pop-ups and its toolbar are not on Light pages. The sign-in page and the two-factor screens print no platform footer line (no copyright, no "Thank you for choosing Mage-OS"): Light's admin_login.xml removes the copyright block that Magento moves into login.footer, and the card shows the active brand instead. Light pages are responsive down to 390px (nav becomes a drawer behind the menu button); stock screens keep Magento's 1024px viewport.


13. Translation rules

  • PHP and templates: __('Text %1', $value); cast to string where a string is required: (string)__('...').
  • Layout XML and di.xml strings: xsi:type="string" translate="true".
  • di.xml strings are not translated by Magento. The DI argument interpreter ignores translate="true" (only layout XML honours it); in di.xml the attribute only lets i18n:collect-phrases find the phrase. Translate such text where you render it, after the admin's locale is known: inject Mrx\Light\Model\I18n\ConfiguredText and call translate(mixed $text): string (__() for non-empty text, '' for empty or non-string input). Light does this for nav labels (Navigation\Config::getItems(), so also the shortcut dialog, config.nav and the advanced-view banner), "Go to" titles and subtitles, "Actions" titles and AdvancedView sections; Mrx_Settings for its settings list, role presets and session lifetimes. Keep the English source searchable: the palette matches a destination or action on its English label too.
  • Magento's language pack for a locale wins over module CSVs, so a MageRex phrase the pack also has shows the pack's wording ("Content" reads "Inhoud", "Search" reads "Zoek" in Dutch). Tests that check Dutch text should use the rendered wording.
  • JS: define(['mage/translate'], function ($t) { $t('Unsaved product'); }). Use a literal string inside $t('...') (the JS dictionary is built by scanning for literal calls) and substitute placeholders afterwards: $t('%1 selected').replace('%1', count).
  • Email templates: {{trans "Hi %name," name=$customer_name}}.
  • Every phrase you add goes into your module's CSV for each locale Light ships ("English","Nederlands" in nl_NL.csv, quoted, sorted). Write for merchants: short, sentence case, no jargon; toasts are noun + verb ("Product saved").
  • Form of address: admin text is informal ("je", "du"). Customer text follows the store's setting (FormOfAddressInterface::forStore()). Write your base nl_NL.csv in the informal form and de_DE.csv in the formal form, and ship the rows that change in i18n/nl_NL.formal.csv and i18n/de_DE.informal.csv: every customer row with a pronoun, and the Dutch greeting. When your customer mail shows a stock phrase with a pronoun, add its other form too. The storefront JS dictionary holds the base form only, and a theme's CSV wins over your variant rows. When a stock template your mail includes (the Luma header or footer) shows a phrase the language pack lacks, add it to your de_DE.csv/nl_NL.csv as well; Light ships the German "Hours of Operation" footer line for that reason.
  • One English phrase has one translation per language and form across the core modules in app/code/Mrx (Mrx\Light\Test\Unit\I18n\TranslationConsistencyTest fails otherwise): Magento merges every module's CSV into one dictionary, so two wordings for "Guest" means one of them silently loses. A country pack outside app/code/Mrx may override a core row on purpose. When a context needs a different word, give it a different English phrase (e.g. "Open (orders tab)").
  • Light's own phrases are in app/code/Mrx/Light/i18n/nl_NL.csv and its variant files per form; don't duplicate them in your module unless you need a different Dutch wording.

14. Environment notes (read before debugging)

  • Files reach the container through Mutagen and can lag a few seconds (up to ~15s seen). After editing XML/PHP, wait until the container has the change (docker exec magerexlight-php-fpm-1 grep -c 'something new' /var/www/html/app/code/...) before cache:clean, or the old config gets cached again and your change seems ignored.
  • Run Magento through the rolldev MCP tools (cache:clean after layout XML, di.xml, routes, ACL, config.xml, system.xml, email templates or new controllers: the router's action list is cached). CSS/JS/templates need no clean in developer mode (JS/CSS are symlinked; hard-reload the browser).
  • Static files are served with Cache-Control: max-age=31536000. URLs are signed (dev/static/sign=1, /static/version<N>/…), so bumping pub/static/deployed_version.txt (in the container: printf %s "$(date +%s)" > /var/www/html/pub/static/deployed_version.txt; the file must not end in a newline, or every static URL breaks) gives every browser new URLs and fresh files. Do that at the end of a fix round. Between bumps a browser that already loaded an older light.css or JS module keeps using it; Playwright starts with a fresh cache every run. In the shared chrome-devtools browser, reload with the cache ignored, or refresh the changed files once from an admin page before reloading: await fetch(document.querySelector('link[href*="Mrx_Light/css/light.css"]').href.replace(/Mrx_Light.*$/, 'Mrx_Orders/js/order-view.js'), {cache: 'reload'}) (one fetch per changed file, then reload the page).
  • After editing a storefront web/css/source/_module.less, also delete var/view_preprocessed/pub/static/frontend/*/*/*/Mrx_<Module> (host and container) and bump the static version. The first request per locale then recompiles styles-*.css; right after the reset a locale can briefly be served without CSS.
  • Never run setup:di:compile or setup:static-content:deploy.
  • Interceptors are compiled (creatuity/magento2-interceptors): each generated/code/…/Interceptor.php has its plugin chain baked in, read from generated/metadata/staticcache/*_compiled_plugins.php, and cache:clean touches neither. After adding, moving or removing a <plugin> in di.xml, delete the staticcache/*.php files and the interceptors of the classes that should change, on the host and in the container; both regenerate on the next request. An interceptor generated earlier keeps its old chain until then.
  • Changing a controller's constructor leaves a stale generated/code/<Vendor>/<Module>/Controller/.../Interceptor.php that still calls the old parent constructor (every action gets an interceptor). Delete that interceptor directory both on the host and in the container (docker exec magerexlight-php-fpm-1 rm -rf /var/www/html/generated/code/...); developer mode regenerates it on the next request. Deleting only on one side can sync the stale copy back.
  • PHP 8.5 turns deprecations into exceptions: no imagedestroy, finfo_close, curl_close, xml_parser_free, Reflection*::setAccessible, implicit nullable parameters, dynamic properties, null array offsets, ${var} interpolation, (integer)/(boolean)/(double) casts.
  • Mail: Magento sends through SMTP to the RollDev Mailpit container (system/smtp/transport=smtp, host=mailhog, port=1025, set in the database for local dev because the PHP sendmail_path (mhsendmail) is rejected by Symfony Mailer). Inbox: https://mailhog.roll.test (API: /api/v1/messages).
  • Demo data: ~1,018 products (IDs start at 13), 6 customers, orders 12-21 (states complete, canceled, new, processing), a few CMS pages. Two channels (section 16): base (store views default Nederlands nl_NL, id 1, and en English, id 3) and outlet (https://outlet.magerexlight.test, outlet_nl id 18 and outlet_de id 19, 100 products, 2 customers, 4 orders). Default-scope (store 0) values are Dutch.

15. Browser tests (Playwright)

npm test runs tests/playwright/**/*.spec.ts against https://app.magerexlight.test (one worker, generous developer-mode timeouts). Each run writes to its own output directory (var/playwright-results/run-<pid>, or MRX_PW_OUTPUT_DIR), so parallel runs no longer wipe each other's traces; --output=<your scratch dir>/pw still overrides it: npx playwright test --config=tests/playwright/playwright.config.ts --output=<your scratch dir>/pw <area>.spec.ts. Stock UI-component grids can take over two minutes to reach load on a cold cache; open them with page.goto(url, { waitUntil: 'domcontentloaded' }) and wait for .data-row. Add your spec as tests/playwright/<area>.spec.ts:

import { expect, test } from './helpers/test';
import { openAdmin, waitForTable } from './helpers/admin';

test('orders list shows the orders to ship', async ({ page }) => {
    await openAdmin(page, 'light/orders/index');
    const table = page.locator('[data-mrx-index-table="orders"]');
    await waitForTable(table);
    await table.getByRole('tab', { name: 'Not shipped' }).click();
    await waitForTable(table);
    await expect(table.locator('.mrx-index-table__row').first()).toBeVisible();
});
  • Always import test/expect from ./helpers/test: it blocks RollDev's /auto-login.js, which otherwise signs the login form in as localadmin and logs out the shared developer browser.
  • The setup project signs in once as lighttest / LightTest2026! through the login form (2FA is off locally, spec D10), switches the user to simple mode and stores the session in tests/playwright/.auth/lighttest.json (git-ignored). Specs start signed in.
  • Admin URLs carry per-session secret keys, so don't hard-code them: openAdmin(page, 'light/orders/view/id/16') / adminUrl(page, path) resolve a keyed URL through GET light/url/resolve?path=... (it only answers same-origin requests that already carry a valid key; admin/security/use_form_key stays on).
  • Helpers in tests/playwright/helpers/admin.ts: login(page, user?, password?), adminUrl, openAdmin (also waits for the shell; when the secret key went stale between resolving and opening, Magento silently redirects to the start page, and openAdmin resolves a fresh URL and opens it once more), setMode(page, 'simple'|'advanced'), waitForShell (html[data-mrx-ready]), waitForTable(locator) (waits until aria-busy is gone), collectErrors(page) (console errors + page errors; assert it is empty).
  • Leave lighttest in simple mode when your spec ends (test.afterEach(() => setMode(page, 'simple')) if you switch).
  • Clean up what a spec creates, with a unique prefix (mrx-…@example.com, MRX… SKUs). Orders go with removeOrders(ids) from helpers/admin.ts: it deletes the orders with their grids, returns and messages and gives back the stock they still hold (ordered minus canceled minus refunds with "Restock"). Canceling or shipping alone leaves them in Analytics, Home, the customer lists and the demo customers' history, and completed ones drain the demo products other specs order. Don't send test messages to demo customers' addresses without deleting them: mrx_customer_message is shown on the customer page by email.
  • Storefront forms: Luma's customer-data listens to every POST form but sets up its storage only in its init, so a submit before that throws in core code. Wait for require(['Magento_Customer/js/customer-data'], (data) => data.getInitCustomerData()) before submitting (see shipping.spec.ts).
  • browser.newContext() inside a test inherits the project's lighttest session; pass storageState: { cookies: [], origins: [] } (and call blockAutoLogin(context)) to sign in as someone else. users-and-permissions.spec.ts does this for a throwaway user with the Settings "Staff" role and checks the nav, the palette, every link on the Light screens that user may open, and 403s on the screens they may not. Run it after adding nav items, destinations, actions or header links.

16. Channels and languages

Spec decisions D11-D14 (end of the design spec). Merchant words: a channel ("verkoopkanaal") is a Magento website, a language ("taal") is a Magento store view inside it. Store groups stay invisible (one per channel). Never show "website", "store", "store view" or "scope" in merchant UI.

Visibility rule (D11). Channel UI only when the install has more than one website; language UI only when the relevant channel (or the install) has more than one store view. Every component below already follows this rule and renders nothing (or hides its column) otherwise, so you can add them unconditionally. For your own markup, ask ChannelProvider::isMultiChannel() / isMultiLanguage($channelId) (PHP) or channels.isMulti() / channels.isMultiLanguage(id) (JS).

Local data: channel 1 base (https://app.magerexlight.test, Nederlands default store 1 + English en store 3, menu root 2) and channel 4 outlet (https://outlet.magerexlight.test, Nederlands outlet_nl store 18 + Deutsch outlet_de store 19, menu root 123 "Outlet"). Don't hard-code these ids in code; tests read them from #mrx-config (config.channels).

The living reference is the "Channels and languages" section of the UI kit (light/kit/index): the kit table has the channel filter and a Channels column, and the section shows both language switcher modes with the "Use default" pattern, the "Applies to" switch with a real override (the outlet's store name) and a locked field, and both Channels cards. tests/playwright/channels-foundation.spec.ts exercises all of it.

16.1 Channel model (PHP)

Inject Mrx\Light\Model\Channel\ChannelProvider. It reads the store manager once per request and caches the result; call reset() after you create or delete a website or store view in the same request.

MethodReturns
getChannels(): list<Channel>every website except admin, sorted by website sort order then id
getChannel(int $channelId): ?Channel, hasChannel(int): bool, getChannelIds(): list<int>
getDefaultChannel(): ?Channelthe website of the default store view
getLanguages(): list<Language>every store view of every channel, channel by channel, each channel's default language first
getLanguage(int $storeId): ?Language, channelOfStore(int $storeId): ?Channelnull for store 0 and unknown ids
getChannelsByRootCategory(int $categoryId): list<Channel>channels whose menu starts at that root (several may share one)
isMultiChannel(): bool, isMultiLanguage(?int $channelId = null): boolwithout an id: the whole install
getChannelName(int $channelId): string'' for an unknown id
getStorefrontUrl(int $storeId, string $path = ''): stringbase URL of that language + path, with ?___store=<code> when the language shares its channel's domain (English on app.magerexlight.test)
toArray(): list<array>the same data as config.channels.list

Mrx\Light\Model\Channel\Channel (final readonly): id, code, name, websiteName, baseUrl, rootCategoryId (menu root), defaultLanguageId, isDefault, sortOrder, languages (list of Language, default first); getDefaultLanguage(): ?Language, getLanguage(int $storeId): ?Language, getStoreIds(): list<int>, isMultiLanguage(): bool, getHost(): string, toArray(). Mrx\Light\Model\Channel\Language (final readonly): id (store id), code, name, locale (nl_NL), channelId, isDefault (the channel's default language), isActive, sortOrder, baseUrl, storefrontUrl; toArray().

A channel's name is the store name set for that website (general/store_information/name at website scope, so "Studio Noord Outlet"), else the inherited store name; when two channels would show the same name, the non-default ones use their website name instead. Use $channel->name everywhere a merchant sees a channel.

Mrx\Light\Model\Channel\ChannelBadge gives a ready badge: forWebsite(?int $websiteId): ?array{label, tone} and forStore(?int $storeId): ?array{label, tone} (neutral tone; null on a single-channel install or for unknown ids). Use it in page-header badges ($header->addBadge($badge['label'], $badge['tone'])) and in your own lists.

$channel = $this->channelProvider->channelOfStore((int)$order->getStoreId());
$language = $this->channelProvider->getLanguage((int)$order->getStoreId());
if ($channel !== null && $this->channelProvider->isMultiChannel()) {
    $subtitle[] = $channel->name;
}
if ($language !== null && $channel?->isMultiLanguage()) {
    $subtitle[] = $language->name;
}

16.2 Tables: channel filter and Channel column

Implement Mrx\Light\Api\IndexTable\ChannelFilterAwareInterface (extends ProviderInterface, no extra methods) instead of ProviderInterface. On a multi-channel install the table then shows an "All channels / {channel}" select left of the search field, keeps the choice in the page URL as ?channel=<website id>, sends it to the data endpoint as channel, resets to page 1 and clears the selection when it changes, fires mrx:table:channel (detail.channel, '' for all) and treats a chosen channel as a filter for the empty state ("No results found"). Only existing website ids reach you:

use Mrx\Light\Api\IndexTable\ChannelFilterAwareInterface;

class Orders implements ChannelFilterAwareInterface, FilterableProviderInterface
{
    public function getRows(Query $query): array
    {
        $channel = $query->getChannelId() !== null ? $this->channelProvider->getChannel($query->getChannelId()) : null;
        if ($channel !== null) {
            $builder->addFilter('store_id', $channel->getStoreIds(), 'in');     // orders, CMS: store ids
            // products, discounts, customers: filter on website_id / website_ids = $channel->id
        }
        // ...
    }
}

$query->getChannelId(): ?int (also in $query->filters['channel']). A provider can combine it with FilterableProviderInterface. To pin a table to one channel (a customer's orders), pass filters channel like any other filter: the select is then left out. Block argument channel_filter (false) hides the select on a table whose provider supports it (e.g. a small table on Home that follows a page-level filter). CMS pages and blocks assigned to "all" (store 0) must still match a channel: filter on array_merge([0], $channel->getStoreIds()), as KitPages does.

Columns. Two column types turn ids into names on the server, so providers only return ids:

TypeCell valueShows
self::TYPE_CHANNELwebsite id, a list of website ids, or ['store' => $storeId]the channel name; "All channels" when the list covers every channel; Studio Noord Outlet · Deutsch for a store id in a channel with several languages
self::TYPE_LANGUAGEstore id (or ['store' => $storeId])the language name

Column option 'display' => 'badge' renders neutral badges instead of text (one per channel). Both columns disappear on an install where they would say nothing (one channel, one language), so declare them unconditionally:

['key' => 'channel', 'label' => (string)__('Channel'), 'type' => self::TYPE_CHANNEL],
// row: 'channel' => ['store' => (int)$order->getStoreId()]           → "Studio Noord Outlet · Deutsch"
['key' => 'channels', 'label' => (string)__('Channels'), 'type' => self::TYPE_CHANNEL, 'display' => 'badge'],
// row: 'channels' => array_map('intval', $product->getWebsiteIds())   → "All channels" or one badge per channel

The data endpoint answers with the resolved channel next to tab, q, sort.

16.3 Language switcher (editors)

Block Mrx\Light\Block\LanguageSwitcher (template Mrx_Light::channel/language-switcher.phtml, JS Mrx_Light/js/language-switcher). It lists a "Default" option (store 0, the default values, marked "Used in every language") and every language; on a multi-channel install it is a menu button grouped per channel ("Studio Noord QA: Nederlands, English · Studio Noord Outlet: Nederlands, Deutsch"), otherwise a segmented control. The trigger shows the current choice as Deutsch · Studio Noord Outlet and a hint line under it says where changes show ("Changes here only show in Deutsch on Studio Noord Outlet."). It renders nothing when there is only one option.

ArgumentDefaultMeaning
modelinklink: every option is a link to the current page with /store/<id>/ (store 0 removes it), so the page reloads with that language's values (products). event: buttons, no reload; the page listens for mrx:languagechange (categories)
paramstorethe request/URL parameter that holds the store id
channel_idall channelsonly this channel's languages
show_defaulttrueinclude the Default option
default_label"Default"label of the Default option
merge_main_languagefalsethe default channel's main language is edited as the default values, so it is not listed on its own and the Default option carries its name (the category editor's model)
route, route_paramscurrent pagewhere the links go in link mode
store_idselected store when the request has no param
hint, label, html_id, enabledtrue, "Language", generated, trueenabled false hides it (a new product has no translations yet)

Link mode (products), in your page layout:

<block class="Mrx\Light\Block\LanguageSwitcher" name="mrx.product.languages" before="-"/>
// controller, before rendering; read the same param when loading values
$page->getLayout()->getBlock('mrx.product.languages')?->setData('enabled', !$isNew);
$storeId = (int)$this->getRequest()->getParam('store');
$storeId = $this->channelProvider->getLanguage($storeId) !== null ? $storeId : 0;
$product = $this->productRepository->getById($id, false, $storeId);

Event mode (categories):

<block class="Mrx\Light\Block\LanguageSwitcher" name="mrx.category.languages">
    <arguments>
        <argument name="mode" xsi:type="string">event</argument>
        <argument name="merge_main_language" xsi:type="boolean">true</argument>
    </arguments>
</block>
document.addEventListener('mrx:languagechange', function (event) {
    // event.detail: {storeId, previousStoreId, option: {storeId, label, channelId, channelLabel, isDefaultValues, code, locale, hint}, switcher}
    form.querySelectorAll('[data-lang-panel]').forEach(function (panel) {
        panel.hidden = Number(panel.getAttribute('data-lang-panel')) !== event.detail.storeId;
    });
});

mrx:languagebeforechange fires first (cancelable, same detail; in link mode also before the link is followed): call event.preventDefault() to keep the current language, e.g. after your own confirm. In event mode the switcher writes the store id to the URL itself (path segment when the URL already has /<param>/<id>/, else ?<param>=<id>), so a reload opens the same language; read it server-side with the block's getStoreId() or your own validated getParam(). JS API: require(['Mrx_Light/js/language-switcher'], function (switcher) { var instance = switcher.get(element); instance.getStoreId(); instance.getOption(); instance.set(3, {silent: true}); }). The save bar's leave guard covers link mode (a dirty form asks before the link is followed).

Moving the Catalog switchers onto it. Products: replace the <nav class="mrx-product-form__languages"> markup in product/form.phtml with the block above (link mode, param store, same URLs as today) and drop ProductForm::getLanguages(); the product form already reads store the same way. Categories: replace the [data-role="language-switch"] button group with the event-mode block (merge_main_language true matches Category\Languages, where store 0 is the default store view), keep the data-lang-panel panels and switch them in a mrx:languagechange listener as above; Category\Languages can then be replaced by ChannelProvider::getLanguages().

"Use default" per field. A store-view field either has its own value or shows the default value. Pattern (the kit's event-mode demo):

<div data-mage-init='{"Mrx_Light/js/use-default": {}}'>
    <div class="mrx-field">
        <div class="mrx-field__label-row">
            <label class="mrx-field__label" for="product-name-3">Title</label>
            <label class="mrx-checkbox mrx-use-default">
                <input class="mrx-checkbox__input" type="checkbox" name="use_default[name]" value="1"
                       data-mrx-use-default="product-name-3" checked>
                <span class="mrx-checkbox__label">Use default</span>
            </label>
        </div>
        <input class="mrx-input" id="product-name-3" name="name" value="Linnen kussenhoes" data-mrx-default-value="Linnen kussenhoes">
        <p class="mrx-field__help">Not translated yet</p>
    </div>
</div>

While the box is checked the field shows data-mrx-default-value (or the value of the element in data-mrx-default-source, a CSS selector), is disabled (so it is not posted and the save bar ignores it) and its .mrx-field gets is-using-default; unchecking enables it and focuses it. Your save controller removes the store-view value for every posted use_default[<code>] (so the default shows again) and writes the others at that store id. Never render the checkbox for store 0. Script that sets the checkbox must dispatch change (or mrx:valuechange to redraw without resetting the value).

16.4 Channels card (products, discounts, pages, blocks)

Block Mrx\Light\Block\ChannelsCard (template Mrx_Light::channel/channels-card.phtml, JS Mrx_Light/js/channels-card). A card with a checkbox per channel and its domain as helper text, everything checked for a new record, and an inline error when nothing is checked. The language level lists channels with their languages as nested checkboxes under "All channels and languages" (the "Visible on" card for pages and blocks); channel checkboxes follow their languages (checked, unchecked or indeterminate).

ArgumentDefaultMeaning
levelchannelchannel posts website_ids[]; language posts store_ids[] (plus 0 when "All channels and languages" is on)
selectednullthe stored ids; null means a new record (all channels, or store 0). From a controller: setSelected(?array $ids)
requiredtruethe inline error and setCustomValidity when nothing is checked
namewebsite_ids / store_idsinput name without []
title"Channels" / "Visible on"card title
help"Customers only see this in the channels you choose." / "Languages you add later are included while everything is on."'' hides it
formid of the form the inputs belong to, when the card is rendered outside that form (a sidebar); the card then also tells that form's save bar about changes
cardtruefalse renders only the fieldset (with a visible legend) for use inside your own card
html_idgenerated

It renders nothing on a single-channel install (channel level) or with one language in total (language level); nothing is posted then, and the server helper below keeps the stored value (or uses every channel for a new record).

<referenceContainer name="your.sidebar.container">
    <block class="Mrx\Light\Block\ChannelsCard" name="mrx.product.channels">
        <arguments>
            <argument name="form" xsi:type="string">product-form</argument>
        </arguments>
    </block>
</referenceContainer>
// edit page
$page->getLayout()->getBlock('mrx.product.channels')?->setSelected($isNew ? null : array_map('intval', $product->getWebsiteIds()));

// save controller (inject Mrx\Light\Model\Channel\ChannelSelection)
try {
    $websiteIds = $this->channelSelection->resolve($this->getRequest(), $isNew ? null : array_map('intval', $product->getWebsiteIds()));
} catch (LocalizedException $e) {
    return $this->resultFactory->create(ResultFactory::TYPE_JSON)->setHttpResponseCode(422)
        ->setData(['success' => false, 'message' => $e->getMessage()]);      // "Choose at least one channel."
}
$product->setWebsiteIds($websiteIds);

Mrx\Light\Model\Channel\ChannelSelection:

MethodDoes
fromRequest(RequestInterface $request, string $param = 'website_ids', bool $required = true): ?list<int>null when not posted; else normalize()
resolve(RequestInterface $request, ?array $current, bool $required = true, string $param = 'website_ids'): list<int>posted value, else $current, else (new record) every channel
normalize(mixed $posted, bool $required = true): list<int>keeps existing website ids only, deduplicated, in channel order; empty + required throws LocalizedException('Choose at least one channel.'), except on a single-channel install where it returns that channel
all(): list<int>every website id
languagesFromRequest(RequestInterface $request, string $param = 'store_ids', bool $required = true): ?list<int>language level; [0] when "All channels and languages" was on or every language was checked
resolveLanguages(RequestInterface $request, ?array $current, bool $required = true, string $param = 'store_ids'): list<int>posted, else $current, else [0]
normalizeLanguages(mixed $posted, bool $required = true): list<int>existing store ids, or [0]; empty + required throws "Choose at least one language."
channelsOfLanguages(list<int> $storeIds): list<int>the website ids behind a store selection ([0] = every channel); use it for a Channels column of CMS rows

Constants: ChannelSelection::PARAM (website_ids), LANGUAGE_PARAM (store_ids), ALL_LANGUAGES (0).

JS (Mrx_Light/js/channels-card): channelsCard.get(elementInsideTheCard) → {getIds(), setIds(ids), isEmpty(), validate() → bool, setError(message), clearError()}; every change fires mrx:channelschange (bubbling, detail: {ids, level, card}; ids [0] = all). For "an Active product needs a channel", call validate() from your save handler (or setError($t('Active products need at least one channel.')) when the status is Active) and repeat the check on the server.

16.5 Settings per channel: "Applies to"

Spec D14. The switch shows "Applies to: All channels | Studio Noord QA | Studio Noord Outlet" (a select from four channels up), puts the channel in the page URL as a path parameter (/channel/4/), and shows a hint ("Changes only apply to Studio Noord Outlet. Everything you leave alone keeps following all channels."). Each field can carry a note: "Changed for Studio Noord Outlet" + "Use the all-channels value (Studio Noord QA)" while the channel has its own value, or "Same for every channel. Change it under All channels." (and the field disabled) for settings Magento keeps once per install (showInWebsite="0"). Only the path parameter counts on a page, so a table's ?channel= filter on the same page never switches the settings scope.

Layout (the view model is shared per request, pass it to your template too):

<referenceContainer name="content">
    <block class="Mrx\Light\Block\ChannelScopeSwitch" name="mrx.settings.scope" before="-">
        <arguments>
            <argument name="form" xsi:type="string">settings-form</argument>   <!-- adds <input type=hidden name=channel form=...> -->
        </arguments>
    </block>
    <block class="Magento\Backend\Block\Template" name="mrx.settings.business" template="Mrx_Settings::page/business.phtml">
        <arguments>
            <argument name="channel_scope" xsi:type="object">Mrx\Light\ViewModel\ChannelScope</argument>
        </arguments>
    </block>
</referenceContainer>

Block arguments: form, route + route_params (default: the current page with its parameters), hint (true), html_id. It renders nothing on a single-channel install.

Template field:

<?php $scope = $block->getData('channel_scope'); $scope->preload([$namePath, $emailPath]); ?>
<div class="mrx-field">
    <label class="mrx-field__label" for="store-name"><?= $escaper->escapeHtml(__('Store name')) ?></label>
    <input class="mrx-input" id="store-name" name="store_name" value="<?= $escaper->escapeHtmlAttr($scope->getValue($namePath)) ?>">
    <?= /* @noEscape */ $scope->fieldNote($namePath, 'store-name') ?>
</div>
<!-- a select: pass the option label of the all-channels value so the button doesn't show a raw code -->
<?= /* @noEscape */ $scope->fieldNote('checkout/options/guest_checkout', 'guest-checkout', $labels[$scope->getDefaultValue('checkout/options/guest_checkout')] ?? '') ?>

Mrx\Light\ViewModel\ChannelScope: isAvailable(): bool, getParam(): string (channel), getChannelId(): ?int, getChannel(): ?Channel, getChannelName(): string, getOptions(string $route = '*/*/*', array $params = []): list<array{channelId, label, url, active}>, getOptionUrl(?int $channelId, string $route = '*/*/*', array $params = []): string (use it for links between settings pages so the channel stays selected), getHint(): string, getValue(string $path): string (the value the selected channel uses, or the all-channels value), getDefaultValue(string $path): string, preload(list<string> $paths) (one query for the overrides of a whole page), getOverridingChannels(string $path): list<int>, isOverridden(string $path): bool, canVary(string $path): bool, isLocked(string $path): bool, fieldNote(string $path, string $fieldId, string $defaultLabel = ''): string (escaped HTML, '' when there is nothing to say). Encrypted fields (obscure, Encrypted backend) never put their value in the note.

"Use the all-channels value" (JS Mrx_Light/js/channel-scope, loaded by the note itself) puts the all-channels value in the field, fires input + change (the save bar lights up), and adds <input type="hidden" name="mrx_use_default[]" value="<path>"> to the field's form; typing a new value or Discard removes it again.

Save controller:

use Mrx\Light\Model\Channel\ChannelConfig;

$channelId = $this->channelConfig->channelFromRequest($this->getRequest());     // null = All channels
$values = [
    'general/store_information/name' => (string)$this->getRequest()->getParam('store_name'),
    'trans_email/ident_general/email' => (string)$this->getRequest()->getParam('sender_email'),
];
// Locked fields are disabled in a channel scope, so they are not posted: only put paths you received in $values.
$values = $this->channelConfig->withUseDefault($values, $this->getRequest());
try {
    $changed = $this->channelConfig->save($values, $channelId, ['full_page']);
} catch (LocalizedException $e) {
    return $this->resultFactory->create(ResultFactory::TYPE_JSON)->setHttpResponseCode(422)
        ->setData(['success' => false, 'message' => $e->getMessage()]);
}

Mrx\Light\Model\Channel\ChannelConfig (constants PARAM = channel, USE_DEFAULT_PARAM = mrx_use_default):

MethodDoes
getValue(string $path, ?int $channelId = null): stringthe channel's effective value (own, else all-channels); null reads the all-channels value
getDefaultValue(string $path): stringall-channels value
isOverridden(string $path, int $channelId): bool, getOverrides(list<string> $paths): array<string, list<int>>which channels have their own row in core_config_data (scope = websites)
getStoreOverrides(?list<string> $paths = null): array<string, list<int>>which store views have their own row (scope = stores), only store views of a channel; null for every path. Kept apart from getOverrides(), whose lists hold channel ids. Settings names these rows under a field ("Changed for Deutsch in the advanced view") and the doctor lists them (store_override)
canVaryByChannel(string $path): boolshowInWebsite of the system.xml field (false for unknown paths)
isSecret(string $path): boolobscure/encrypted field
channelFromRequest(RequestInterface $request, string $param = 'channel'): ?int, toChannelId(mixed $value): ?intexisting website id or null
withUseDefault(array $values, RequestInterface $request, string $param = 'mrx_use_default'): arrayposted "use the all-channels value" paths become null
save(array $values, ?int $channelId = null, list<string> $cacheTypes = []): list<string>writes through Magento\Config\Model\Config (backend models, validation and encryption run), per section; returns the changed paths. All channels: only values that differ from the stored default. A channel: a value equal to what the channel reads now is skipped (so untouched fields keep following all channels), any other value is written at website scope as the channel's own, also one equal to the all-channels value, and only null removes the channel's own value; throws LocalizedException for a path that can't vary per channel or a channel that no longer exists. Masked values (******) are never written; array values (multiselects) are joined with commas. Cleans config plus $cacheTypes and reinitialises the config when something changed
useDefault(list<string> $paths, int $channelId, list<string> $cacheTypes = []): list<string>removes the channel's own values
cleanCache(list<string> $types = [])config + types, ReinitableConfigInterface::reinit(), ChannelProvider::reset()

Store-view (language) values are not part of this contract; keep using your own store-scope writer for translations.

16.6 Top bar and command palette

Nothing to do for the top bar: with several channels the store name opens a "View store" menu with one entry per channel (name, domain, default language; opens in a new tab) and its other languages below it (?___store= links when a language shares the domain). One channel keeps the plain link. The owner can hide this button and menu under Settings > Appearance > Top bar (mrx_light/top_bar/store_button, on by default); then Home's "View store" is the way to the shop, and on phones it stays in "More actions".

Search results: return store (a store id) or channel (a website id) on an item and the palette adds a neutral channel badge next to your status badge on a multi-channel install (GET light/search/index items carry channel: {label, tone}|null). The built-in customers group does this; Mrx_Orders replaces the built-in orders group and should add 'store' => (int)$row['store_id'] to its items (the grid table has the column).

$items[] = ['title' => '#' . $row['increment_id'], 'url' => $url, 'icon' => 'orders', 'store' => (int)$row['store_id'], 'badge' => $status];

16.7 JavaScript: Mrx_Light/js/channels

config.get('channels') is {multi, multiLanguage, defaultId, list: [{id, code, name, host, url, rootCategoryId, defaultLanguageId, isDefault, languages: [{id, code, name, locale, isDefault, url}]}]}. The helper module wraps it:

define(['Mrx_Light/js/channels'], function (channels) {
    channels.isMulti();                  // D11 check
    channels.isMultiLanguage(4);         // one channel, or the install without an argument
    channels.all(); channels.get(4); channels.name(4); channels.defaultId();
    channels.languageOf(19);             // {channel, language} | null
    channels.pick({title: $t('Add to channel'), message: $t('The selected products will be sold here too.'),
                   confirmLabel: $t('Add to channel'), exclude: [], selected: 4, skipSingle: true})
        .then(function (channelId) { /* null when cancelled */ });
});

pick() opens a small modal with one radio per channel (Cancel/Esc resolves null); use it for bulk "Add to channel" / "Remove from channel" before posting ids[] plus the chosen channel to your controller.

16.8 CSS

mrx-index-table__filters, mrx-index-table__channel (table toolbar), mrx-language-switcher (__row, __label, __trigger, __menu, __option, __check, __hint, --grouped), mrx-popover__title (section title inside any popover), mrx-channels (__all, __list, __group, __languages), mrx-channels-card, mrx-scope-switch (__row, __label, __select, __hint), mrx-scope-note (--locked, --store for a store view's own value, __reset, __done), mrx-field__label-row (label left, "Use default" right), mrx-use-default, mrx-storefront-menu, mrx-palette__item-channel. Don't restyle them per module.

16.9 Checklist per area

  • Lists: implement ChannelFilterAwareInterface, add a TYPE_CHANNEL column, filter on $query->getChannelId().
  • Editors of catalog/CMS content: LanguageSwitcher + "Use default"; products, discounts, pages: ChannelsCard + ChannelSelection::resolve() / resolveLanguages(); a search listing: SearchPreview for the language and channel being edited (section 4).
  • Settings: ChannelScopeSwitch + ChannelScope::fieldNote() + ChannelConfig::save().
  • Search groups with orders or customers: add store / channel to items.
  • Headers and cards that show one record: ChannelBadge::forStore() / forWebsite() or the channel name, never the website code.
  • Test both storefronts where the channel changes what a customer sees.

17. Extending Light (bridges and apps)

The extension guide moved to the developer guide. This section keeps one line per old subsection, so a reference such as "ui-kit 17.4.10" in a code comment still leads to the right page. Admin themes keep their contract in Admin themes.

17.1 Three ways to plug in

Moved to Light developer guide: pattern A, pattern B and a per-client change, and the rules every module follows.

17.2 The module gate

Moved to Light developer guide, "The module gate".

17.3 Catalogue of extension points

Moved to Extension targets, which the doctor generates from the catalogue.

17.4 Contracts

Each contract has its recipe below. API reference lists every @api type.

17.4.1 Nav items: static and runtime

Moved to A nav item with a counter.

17.4.2 Badges

Moved to A nav item with a counter.

17.4.3 Light pages and lists

Moved to Your own list and detail pages and Light lists.

17.4.4 Columns and bulk actions on another module's table

Moved to Light lists.

17.4.5 Palette groups, "Go to" and "Actions"

Moved to The command palette.

17.4.6 Redirects

Moved to The advanced view, the route map and header actions.

17.4.7 The way back from a stock screen

Moved to The advanced view, the route map and header actions.

17.4.8 Icons

Moved to Your own list and detail pages.

17.4.9 Slots

Moved to Fields on a Light editor for the editors and Order and customer pages for the order and customer pages. ?mrx_slots=1 outlines them in developer mode.

17.4.10 Page modules

Moved to the recipe of each page module: Orders and Customers to Order and customer pages, Catalog to Fields on a Light editor, Home to Home to-dos, setup steps and widgets, Settings to A settings page from your system.xml, and Apps to An app, its category and pins.

17.5 Worked example: what Mrx_Returns registers

Order and customer pages and The advanced view, the route map and header actions quote what Returns registers. The worked example of a bridge is Case study: disrex/module-request-an-account.

17.6 Checklist for bridge authors

Moved to Getting started.

17.7 Events

Moved to Events, JS modules and #mrx-config keys.

17.8 Versioning and packaging

Moved to Versioning.

17.9 Changelog

Moved to Changelog.

Last updated on

On this page

1. What already exists in your module2. A new Light pageControllerLayoutTemplate skeleton3. Page header (mrx.page.header)4. Components (CSS)Tokens (:root)LayoutCardButtonsForm fieldsBadgeTabsEmpty state, banner, thumbnail, avatar, tag, chip, kbdDescription list, timeline, skeleton, popover, text helpersLoading statesAdvanced section (end of a settings page)Payment provider card (Settings)Emails & documents screen (Settings)Touch targets on phonesIconsSearch result preview (Google)5. JavaScript modules6. A form with the save bar and a JSON controller7. IndexTable: provider + pageProviderPageColumns: width, scrolling and choosing columnsBulk actionsBulk action controllerRow actions8. Navigation: items, children and badges9. Simple-mode redirects from stock screens10. Search and the command palette11. Contact customer (email from anywhere)12. Modes, shell and layout handles13. Translation rules14. Environment notes (read before debugging)15. Browser tests (Playwright)16. Channels and languages16.1 Channel model (PHP)16.2 Tables: channel filter and Channel column16.3 Language switcher (editors)16.4 Channels card (products, discounts, pages, blocks)16.5 Settings per channel: "Applies to"16.6 Top bar and command palette16.7 JavaScript: Mrx_Light/js/channels16.8 CSS16.9 Checklist per area17. Extending Light (bridges and apps)17.1 Three ways to plug in17.2 The module gate17.3 Catalogue of extension points17.4 Contracts17.4.1 Nav items: static and runtime17.4.2 Badges17.4.3 Light pages and lists17.4.4 Columns and bulk actions on another module's table17.4.5 Palette groups, "Go to" and "Actions"17.4.6 Redirects17.4.7 The way back from a stock screen17.4.8 Icons17.4.9 Slots17.4.10 Page modules17.5 Worked example: what Mrx_Returns registers17.6 Checklist for bridge authors17.7 Events17.8 Versioning and packaging17.9 Changelog