Skip to content

CSS and JavaScript in your module

Nebula is a parentless admin theme built with Tailwind CSS 4 and esbuild. That changes two things a Magento module author is used to: LESS under view/adminhtml/web/css/source is never compiled, and RequireJS is not on the page. This page describes the stylesheet and JavaScript integration contract.

Stylesheets

Nebula ships one compiled stylesheet, built by Tailwind from the theme and Nebula's own modules and committed. Two routes follow for a module of your own, in the order to reach for them.

1. Plain CSS, loaded from your screen's layout

Write plain CSS against Nebula's tokens and component classes and load it from the layout of the screen it belongs to:

<css src="Vendor_Module::css/thing.css"/>

No build and no Tailwind. Written against var(--nebula-*) and the .nebula-* component classes, as in the example under What works below, it follows every skin and dark mode. This is the route to reach for first, and for most modules it is the only one needed.

2. Everything else: a project build into a child theme

From the theme's web/, npm run build:css:project -- --out <child>/web/css/dist/app.css compiles the theme's stylesheet with app/code/*/*/view/adminhtml, vendor/*/*/view/adminhtml and app/design/adminhtml/*/* added to the scan, and writes it into a child theme rather than over the packaged file (it refuses to run without --out, because a project scan makes the result depend on which extensions the building machine has installed). Select the child under Stores › Configuration › Nebula › Nebula Admin Theme › Settings › Admin Theme and ordinary theme fallback serves it. See A theme of your own.

This is also the only way to use a Tailwind utility from a module's own markup, for the reason under What does not work.

What does not work

Magento's convention, view/adminhtml/web/css/source/_module.less, is pulled in by the Magento/backend theme's own stylesheet. Nebula has no parent theme and builds its stylesheet with Tailwind, so it compiles nobody's LESS. A module that styles its admin that way arrives with its markup and none of its rules, and nothing logs it.

A Tailwind utility written in a module's template does not work either, unless that exact class already appears somewhere the theme scans. The theme's stylesheet is compiled from the theme package and committed, and your module is not on its scan list, so the class is emitted nowhere and styles nothing. A project build is what puts your templates on the scan list.

And even a utility the theme does generate will not win against a .nebula-* component on any property that component sets. app.css imports Tailwind first and then the component partials unlayered, and an unlayered rule beats every layered one whatever the specificity. So .nebula-control { padding: 0 11px } outranks .pl-9 and always will. A property the component never declares is unaffected, so mt-4 on a .nebula-control applies normally. Anything a component already declares, its box, its border, its type scale, is the component's business: write that rule in your own stylesheet scoped to your own wrapper, or reach for a utility only on markup no .nebula-* class touches.

Nothing reports this

The class is spelled correctly and Tailwind emits it. The only evidence that it never applied is the computed value in the inspector.

What works

Plain CSS written against Nebula's tokens and component classes, loaded from the layout of the screen it belongs to:

view/adminhtml/layout/vendor_module_thing_edit.xml
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <head>
        <css src="Vendor_Module::css/thing.css"/>
    </head>
</page>
view/adminhtml/web/css/thing.css
.vendor-thing {
    background: var(--nebula-bg-secondary);
    color: var(--nebula-text-primary);
    border: 1px solid var(--nebula-border-default);
    border-radius: var(--nebula-radius-md);
    padding: var(--nebula-spacing-md);
    font-family: var(--nebula-font-family, inherit);
}

.vendor-thing__title {
    font-size: var(--nebula-font-size-lg);
    color: var(--nebula-text-secondary);
}

.vendor-thing--warning {
    border-color: var(--nebula-color-warning);
    background: var(--nebula-color-warning-light);
}

Most colour tokens are declared with light-dark(), so a stylesheet that reads tokens follows the active skin and dark mode with no second rule set. The full list is on the Token reference.

The theme's component classes are stable and may be used freely. The ones you will reach for most:

Family Classes
Buttons nebula-btn, nebula-btn--primary, nebula-btn--ghost, nebula-btn--soft, nebula-btn--danger, nebula-btn--xs
Alerts nebula-alert, nebula-alert--success, nebula-alert--warning, nebula-alert--error
Chips nebula-chip, nebula-chip--ok, nebula-chip--error, nebula-chip--warning, nebula-chip--info, nebula-chip--muted
Drawers nebula-drawer--dialog, nebula-drawer__head, nebula-drawer__body, nebula-drawer__foot
Forms nebula-checkbox, nebula-action-row, nebula-code-input

module.css is one cached request on every page and is the right home for rules the module uses on more than one screen; a stylesheet used by one screen loads from that screen's handles.

JavaScript

What is on the page

Every admin page loads, as synchronous <script>s in before.body.end and in this order: nebula.i18n (emits window.NebulaI18n), nebula.vendor.sortable, then nebula.component.scripts — nebula-core.js, which installs window.Alpine, window.Nebula, window.NebulaField and the shared components, and starts Alpine at the end of its own turn, so a sibling script that registers on alpine:init is seen. Pages with a grid or a form also load nebula-grid.js or nebula-form.js. There is no RequireJS. A shim swallows a legacy require() or define() call and logs a console warning, so a script that starts with require([...]) never runs its callback; Fieldset.*, fireEvent() and toggleValueElements() are stubs of the same kind. In developer mode every swallowed call is also summarised on a small pill at the foot of the page — "3 legacy call(s) swallowed on this page: require(), Fieldset.toggleCollapse()…" — and clicking it prints the ledger with console.table, so a vendor screen that leans on the legacy shell is visible without opening the console.

Adding a script

Your module owns its file — plain JavaScript, or the output of your own esbuild or tsc — and loads it from layout. build.mjs in the theme is a hardcoded list of the theme's own bundles and a module never joins it. Place the script after the core bundle, the same shape the theme's own bundles use:

view/adminhtml/layout/default.xml
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceContainer name="before.body.end">
            <block class="Magento\Framework\View\Element\Template"
                   name="vendor.module.scripts"
                   template="Vendor_Module::scripts.phtml"
                   after="nebula.component.scripts"/>
        </referenceContainer>
    </body>
</page>
view/adminhtml/templates/scripts.phtml
<script src="<?= /* @noEscape */ $block->getViewFileUrl('Vendor_Module::js/thing.js') ?>"></script>

A script loaded that way runs after nebula-core.js and before Alpine starts, so registering on alpine:init is always in time. A linked file in <head> with defer also works and needs no nonce under an enforced CSP (script-src 'self' covers it); it runs after Alpine has started, which is why the pattern below also registers immediately when Alpine is already present:

<head>
    <script src="Vendor_Module::js/thing.js" defer="defer"/>
</head>

Register your Alpine component on alpine:init, and also immediately if Alpine is already present, because the order in which packages' scripts load is not guaranteed:

view/adminhtml/web/js/thing.js
(function () {
    'use strict';

    function register(Alpine) {
        if (Alpine.__vendorThingRegistered) {
            return;
        }
        Alpine.__vendorThingRegistered = true;

        Alpine.data('vendorThing', function (config) {
            return {
                open: false,
                busy: false,
                error: '',
                toggle() { this.open = !this.open; },
                async send() {
                    if (!window.Nebula.validateForm(this.$root.closest('form'))) {
                        return;
                    }
                    if (!(await window.Nebula.confirm({
                        title: config.labels.confirmTitle,
                        message: config.labels.confirmMessage,
                    }))) {
                        return;
                    }
                    this.busy = true;
                    try {
                        const response = await fetch(config.url, {
                            method: 'POST',
                            headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
                            body: JSON.stringify({ form_key: config.formKey, id: config.id }),
                        });
                        const body = await response.json();
                        if (!body.success) {
                            throw new Error(body.message || config.labels.failed);
                        }
                        window.nebulaToast('success', config.labels.done);
                    } catch (e) {
                        this.error = e.message;
                    } finally {
                        this.busy = false;
                    }
                },
            };
        });
    }

    document.addEventListener('alpine:init', function () { register(window.Alpine); });
    if (window.Alpine) {
        register(window.Alpine);
    }
})();

And in the template, with translated strings handed in rather than hard-coded in the script:

<?php
/** @var \Magento\Framework\View\Element\Template $block */
/** @var \Magento\Framework\Escaper $escaper */
$config = [
    'url' => $block->getUrl('vendor_module/thing/send'),
    'formKey' => $block->getFormKey(),
    'id' => (int) $block->getData('thing_id'),
    'labels' => [
        'confirmTitle' => (string) __('Send this thing?'),
        'confirmMessage' => (string) __('It goes out immediately.'),
        'done' => (string) __('Sent.'),
        'failed' => (string) __('The send failed.'),
    ],
];
?>
<div x-data="vendorThing(<?= $escaper->escapeHtmlAttr(json_encode($config)) ?>)" class="vendor-thing">
    <button type="button" class="nebula-btn nebula-btn--primary" :disabled="busy" @click="send()">
        <?= $escaper->escapeHtml(__('Send')) ?>
    </button>
    <p class="nebula-alert nebula-alert--error" x-show="error" x-text="error"></p>
</div>

Inline scripts and the CSP nonce

An inline <script> must carry the request nonce or an enforced CSP drops it. Magento injects $secureRenderer into every template:

<?php
$js = <<<'SCRIPT'
document.addEventListener('alpine:init', () => {
    Alpine.data('vendorSmallThing', () => ({ open: false }));
});
SCRIPT;
?>
<?= /* @noEscape */ $secureRenderer->renderTag('script', [], $js, false) ?>

When the script needs a PHP value, build the string with a heredoc and escape every value with $escaper->escapeJs().

The public JavaScript surface

window.Nebula is the API a module may call. Everything else in the bundles is internal.

Function What it does
validateField(input), validateForm(form) Run the declared rules; return a boolean and mark fields
validateValue(value, rules) Validate a value against a rules object; returns the message or null
rule(name, fn, message) Register or override a validation rule
registerValidator(fn) Add a whole-form validator that returns the fields in error
setFieldError(field, message), clearFieldError(field) Show or clear a message under a field
confirm({ title, message, confirmText, cancelText, danger, acknowledge }) The admin's confirm dialog; resolves a boolean. title and message are both rendered, so pass both. acknowledge: true renders one button instead of two, labelled OK unless you pass confirmText, for a refusal the admin can only read and acknowledge. That is what replaces alert(), and the promise still resolves
prompt({ title, message, label, value, placeholder, confirmText, cancelText, validate }) Ask for a string; resolves the trimmed string, or null if the admin cancels. validate(value) runs against that trimmed string and returns a message to refuse the value and keep the dialog open, shown under the field, or null to accept. It saves you doing "prompt, then explain afterwards why not"
modal(config) The modal mixin for a native <dialog>. See Modals below
postAction(url) POST to a state-changing controller with the form key
showSaveLoader(), hideSaveLoader() The page-level busy overlay. Installed by nebula-form.js, so present on form pages only. It also comes down by itself when the browser refuses a submit
t(text, ...args) Translate the way the bundles do: window.NebulaI18n lookup, the source string is the key, %1…%n are Magento placeholders. See Translating your strings
developerMode True in developer mode; for diagnostics only

window.nebulaToast(type, message) shows a toast, with type one of success, error, warning, notice. window.NebulaField.base(config) is the base every field component is built on — the model contract (value, fieldName, disabled, validation, error, registration in the nebulaModels store), which is how a custom field type's value reaches the POST without a hidden input. Every one of these is declared in the published types.

Translating your strings

The bundles translate through window.NebulaI18n, a dictionary the page emits server-side, and window.Nebula.t() is the same lookup. A module adds its own strings by implementing Nebula\Component\Api\V1\JsPhrasesProviderInterface and binding it into Translations:

Model/JsPhrases.php
class JsPhrases implements \Nebula\Component\Api\V1\JsPhrasesProviderInterface
{
    public function phrases(): array
    {
        return [__('%1 of %2 stars'), __('Clear rating')];
    }
}
etc/di.xml
<type name="Nebula\Component\ViewModel\Translations">
    <arguments>
        <argument name="providers" xsi:type="array">
            <item name="vendor_module" xsi:type="object">Vendor\Module\Model\JsPhrases</item>
        </argument>
    </arguments>
</type>

Return __() calls rather than strings, so bin/magento i18n:collect-phrases puts them into your module's CSV; the source string is the key on the JavaScript side (window.Nebula.t('%1 of %2 stars', 3, 5)), a phrase that translates to itself is dropped, and a later provider wins for a string two modules share. Handing translated strings into a component's config from the template, as the example above does, remains fine for strings one template owns. Bind it in etc/di.xml, not an area file — see the registry rule.

Types for your TypeScript

design/adminhtml/Nebula/backend/web/types/nebula.d.ts declares the whole public surface — window.Nebula, window.NebulaField, window.NebulaI18n, window.nebulaToast, and the field, modal, grid and dialog shapes. It is generated from the theme's own ts/types.ts (npm run build:types) and a test fails when it lags, so it cannot lie about the runtime. Reference it from your module's TypeScript with a triple-slash path into vendor/ and install alpinejs as a dev dependency for the Alpine types it imports:

/// <reference path="../../../../../../vendor/nebula/theme-adminhtml-backend/web/types/nebula.d.ts" />

Anything not declared there is internal and may change in a patch release.

window.NebulaDirective is the second public global. It exists on pages that load nebula-directive.js, which Nebula_Directive adds in before.body.end. openVariable({ directive }) and openWidget({ directive }) open the variable and widget pickers and resolve to the chosen directive string, or to null if the admin cancels; the directive you pass in only preselects the current value, and both arguments are optional. meta is the catalogue behind those pickers, so a custom editor can build its own menu instead: meta.variables is keyed <type>:<code>, for example custom:my_var, with label, group and directive; meta.widgets is keyed by the widget's block class, for example Magento\Cms\Block\Widget\Block, with name, description and placeholderUrl.

Feature-detect it

Every member of window.NebulaDirective is optional, and the global only appears once the picker components have run their init(). Call it as window.NebulaDirective?.openWidget?.(…) rather than assuming it is there.

Modals

window.Nebula.modal(config) returns a mixin you spread into your own Alpine component. It drives a native <dialog> with showModal() and close(), so the top layer, the ::backdrop scrim, Esc to close, focus containment and focus return on close are all the browser's, and there is no scrim element, z-index or focus trap for you to write.

Alpine.data('vendorThing', () => Object.assign(
    {},
    window.Nebula.modal({ title: 'Edit thing', size: 'lg' }),
    { busy: false }
));
<div x-data="vendorThing()">
    <button type="button" class="nebula-btn" @click="openModal()">Edit</button>

    <dialog x-ref="nebulaModal" class="nebula-modal"
            @cancel.prevent="closeModal()" @click.self="closeModal()">
        <div :class="modalSizeClass">
            <h3 x-text="modalTitle"></h3>
        </div>
    </dialog>
</div>

The x-ref must be spelled exactly nebulaModal. That is how the mixin finds the dialog, and a modal with any other ref opens nothing.

Config key Meaning
title Read back as modalTitle. Empty by default
size One of sm, md, lg, xl, full. Defaults to lg
steps Labels for a wizard. Empty by default, which makes a plain modal
onOpen, onClose Callbacks run with your component as this

The methods are openModal(), closeModal(), nextModalStep(canProceed), prevModalStep() and goToModalStep(index). canProceed is optional and is called with your component as this; returning false holds the step. The read-only helpers are modalSizeClass, isFirstModalStep, isLastModalStep, hasModalSteps, currentModalStepLabel and modalStepCount.

A stepped modal cannot be spread

The helpers above are getters. Object.assign and the ... spread call each one once and copy the value, which freezes it. That is harmless for a plain modal, because nothing it reads ever changes, but a wizard composed that way shows a permanently wrong step state. For a modal with steps, either add your own state onto the mixin object and return that, or copy the accessors live:

const state = { busy: false };
Object.defineProperties(state, Object.getOwnPropertyDescriptors(
    window.Nebula.modal({ steps: ['One', 'Two'] })
));

Opening and closing each dispatch a bubbling event from the component's root element, which is the only hook another component has on a Nebula modal. nebula-modal:open fires on the next tick, after showModal(). nebula-modal:close fires synchronously.

Forms

A Nebula form owns every field inside it through the model store. A widget you add inside a form must not contain a second <form> element, and a field you add that should be posted must register a model or write a plain named input.

Alpine.store('nebulaModels') holds the models, and the key is not the field name. Every nebulaField_* component registers under field:<fieldName>, where <fieldName> is the name the field posts. On a form with settings.fieldPrefix: "product" that name is product[sku], so the key is field:product[sku]. get('sku') returns null. Composite panels register their own keys instead: section:conditions, section:customOptions, section:addresses, section:bundleOptions, section:configurable, and order-address:<scope>.

The store contract your module may use:

Member What it does
register(key, model), unregister(key) Add or remove a model. Registering a key twice replaces the first
get(key) The model, or null when nothing is registered under that key
nextId() Mints __model_1, __model_2 and so on, for a model with no natural key
validateAll() Calls validate() on every model that has one; returns a boolean
serializeAll(form) Writes the hidden inputs the form will post
snapshotAll(), restoreAll(snapshot) How form reset works. See below
debug() Every model's serialized pairs, keyed by model key. The fastest way to see what a form will post

A model is any object with serialize(): {name, value}[]. It may also have validate(): boolean, destroy(), and a value property.

Reset goes through the store because form.reset() cannot do it. A native reset restores each control's defaultValue, and a Nebula field has none: its markup carries no value= attribute, because Alpine writes the value in from the model. So form.reset() blanks the record on screen. snapshotAll() and restoreAll() reset the models instead, and every bound control repaints itself. Note that snapshotAll() skips any model with no value property, so the composite sections listed above are not restored by it.

data-nebula-model is reserved

serializeAll() removes every [data-nebula-model] element in the form and re-creates them on each submit. A module must not write its own inputs carrying that attribute, because they will be deleted before the form posts.

Partial page reloads

Core's submitAndReloadArea(area, url) lived in Prototype's mage/adminhtml/tools.js. Nebula ships no window.submitAndReloadArea shim, so a hand-written inline onclick="submitAndReloadArea(...)" throws ReferenceError: submitAndReloadArea is not defined on click. There is no [Nebula] console line for it, because it is not an inert shim; it is absent.

That call survives on a widget button. A button built with Magento\Backend\Block\Widget\Button whose onclick is exactly submitAndReloadArea($('some_id'), 'url') keeps working, because Nebula's widget/button.phtml recognises that call and renders it as reload attributes instead.

Two more shapes are rebuilt rather than dropped. An onclick of setLocation('url') becomes a plain Nebula link, and so does the unquoted setLocation(window.location.href) that a generic form container uses for its Reset button. An onclick that calls $('edit_form').submit() becomes a real type="submit" button aimed at that form. Any other onclick is dropped, and in developer mode the button is marked data-nebula-dropped-onclick so you can find it in the DOM. In default or production mode nothing is written, so an unmarked button is not proof its onclick was kept.

For your own markup, use the attributes directly:

view/adminhtml/templates/thing.phtml
<div id="vendor-thing-totals"><!-- the region to replace --></div>

<button type="button"
        class="nebula-btn"
        data-nebula-reload-area="vendor-thing-totals"
        data-nebula-reload-url="<?= $escaper->escapeUrl($block->getUrl('vendor_module/thing/totals')) ?>">
    <?= $escaper->escapeHtml(__('Update totals')) ?>
</button>

nebula-core.js binds these by delegation on the document, so it also covers a button that arrives inside a replacement, which is the usual case because the button normally sits in the region it replaces.

On click it serialises the named container's own controls, meaning every named, non-disabled input, select and textarea inside it. Unchecked checkboxes and radios are skipped, and a multi-select contributes every selected option. It appends isAjax=true to the URL, adds the admin form_key if the area did not already carry one, and POSTs form-encoded with X-Requested-With: XMLHttpRequest and same-origin credentials. Then:

  • A JSON body with ajaxExpired and ajaxRedirect navigates to the redirect.
  • A JSON body with error shows its message as an error toast.
  • Anything else replaces the container's innerHTML, and Alpine initialises any x-data in the new markup.

A non-2xx response or a request that never completes shows an error toast rather than looking like it worked. The container carries aria-busy="true" while the request is in flight.

Rebuilding the theme

You only rebuild the theme when you change the theme's own sources, or when you fork it. From design/adminhtml/Nebula/backend/web:

npm ci
npm run build       # esbuild bundles, the Tailwind stylesheet and types/nebula.d.ts
npm run watch       # during development
npm run typecheck
npm test            # includes the gates: the committed stylesheet and the types must match a fresh build

The build empties js/dist before writing, so a failed build deletes committed bundles. After any change to package.json, run npm install before npm run build, and check git status for deletions in js/dist before committing. Only the declared @source globs are scanned (source(none)), so a literal utility name in a comment or a test fixture no longer leaks into the stylesheet — and .php is scanned on purpose, so one in a PHP docblock still would; the dist gate catches it.

A theme of your own

A merchant theme declares <parent>Nebula/backend</parent>, and the three components that decide "is this a Nebula theme" agree: the resolver that forces the admin theme honours any registered descendant chosen under Stores › Configuration › Nebula › Nebula Admin Theme › Settings › Admin Theme and falls back to the packaged theme for anything else; that select lists only descendants; and the theme switcher's "is Nebula active" answer treats a descendant as Nebula, so every Nebula plugin stays on. A child overrides theme-level files through ordinary fallback — root.phtml, the menu, the login shell, the logos under web/images/, and css/dist/app.css, which is where a project build lands. Verified on a child carrying a login_footer.phtml override: the override renders and all seven Nebula bundles stay, through the select, through the switcher and through Mage-OS's own Admin Theme field, which the switcher mirrors.

Custom modules

Use Extension points for field renderer, data provider and translation registrations, and Generators to scaffold a module for your own namespace.