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:
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:
<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>
.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:
<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>
<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:
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:
(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:
class JsPhrases implements \Nebula\Component\Api\V1\JsPhrasesProviderInterface
{
public function phrases(): array
{
return [__('%1 of %2 stars'), __('Clear rating')];
}
}
<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:
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:
<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
ajaxExpiredandajaxRedirectnavigates to the redirect. - A JSON body with
errorshows itsmessageas an error toast. - Anything else replaces the container's
innerHTML, and Alpine initialises anyx-datain 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.