NebulaNebula
Architecture

JS Architecture

Why design/.../web/ts exists, and how the core/template seam keeps Nebula stable without locking templates out of the familiar Alpine.js ergonomics.

The thesis

Templates are the part of an admin that changes most — colors, copy, the order of fields, the wrapping div, the Alpine click handler. They want to be easy to edit, no build step, familiar syntax.

But the contract underneath — "register a model, validate it, serialize it into the form on submit" — is the part that needs to stay stable, typed, and tested. If a template breaks, the page looks wrong. If the contract breaks, every form across every admin page breaks at once.

Nebula splits the difference: a TypeScript core ships the contract — model store, validator, factory APIs — bundled and versioned. Templates use plain Alpine x-data on top of that core, with the same @click / x-show / x-model they already know.

What lives where

In web/ts (TypeScript core)

  • Model store + serialization contract
  • Validator (required, min, max, email, pattern, custom)
  • Toast, modal mixin, focus trap
  • Field factories (nebulaField_text, nebulaField_select, …)
  • Section factories (nebulaForm, nebulaGrid, nebulaRuleEditor, …)
  • System config wiring (depends, test connection, image preview)
  • Vendor compat shims (setLocation, $ for legacy onclick handlers)
  • Type definitions exported for module authors

In view/adminhtml/templates/*.phtml

  • HTML structure (the part that defines the page)
  • x-data="factory(config)" bindings to instantiate factories
  • Inline directives (@click, x-show, x-model, x-text)
  • Per-template state (x-data="{ open: false }" for ad-hoc UI)
  • Conditional rendering, loops (x-for)
  • Tailwind classes, inline SVG, escape calls
The split is enforced by repo layout, not code. web/ts compiles to a versioned bundle; phtml templates can't accidentally import from it. They communicate only via the registered factory names, like nebulaField_text. Rename a factory in TS and you must touch every template that uses it — but that's a deliberate, grep-able break, not a silent contract drift.

The factory pattern

A factory is a TypeScript function registered as an Alpine.data component. It takes a typed config object and returns a state object with methods. Templates call it from x-data.

1

The TS factory

ts/fields/text.ts
import { createBaseField } from './base';
import type { NebulaFieldConfig } from '../types';

export function registerTextField(): void {
  document.addEventListener('alpine:init', () => {
    window.Alpine.data('nebulaField_text', (config: NebulaFieldConfig = {}) =>
      createBaseField(config),
    );
  });
}
2

The base behavior — auto-register

ts/fields/base.ts (excerpt)
export function createBaseField(config: NebulaFieldConfig): BaseFieldState {
  return {
    value: config.value ?? '',
    fieldName: config.fieldName ?? '',
    error: null,

    init() {
      if (this.fieldName) {
        const store = window.Alpine.store('nebulaModels') as NebulaModelStore;
        store.register('field:' + this.fieldName, this);
      }
    },

    serialize() {
      return [{ name: this.fieldName, value: this.value }];
    },

    validate() {
      this.error = window.Nebula.validateField(this.$el);
      return this.error === null;
    },
  };
}
3

The template — Alpine binding

view/adminhtml/templates/field/text.phtml
<div x-data="nebulaField_text({
  fieldName: '<?= $block->escapeHtmlAttr($fieldName) ?>',
  value:     <?= json_encode($fieldValue) ?>,
  required:  <?= $isRequired ? 'true' : 'false' ?>
})">
  <input type="text"
         x-model="value"
         @blur="validate()"
         class="nebula-input">
  <p x-show="error" x-text="error" class="error"></p>
</div>
4

Save: store collects, serializes, submits

ts/nebula-form.ts (saveWithBack)
async saveWithBack(form: HTMLFormElement) {
  const models = window.Alpine.store('nebulaModels');
  if (!models.validateAll())   return;
  if (!Nebula.validateForm(form)) return;

  Nebula.showSaveLoader();
  models.serializeAll(form);   // injects <input type="hidden"> for every model
  form.submit();               // Magento save controller receives standard POST
}

Bundle layout

esbuild builds 11 IIFE bundles in parallel. Page templates load only what they need; the core boots once and stays cached.

BundleLoaded onContains
nebula-core.jsevery admin pageAlpine + plugins, model store, validator, toast, modal, all field factories, system-config bridges
nebula-form.jsform pagesnebulaForm factory: save orchestration, Ctrl+S, save loader
nebula-grid.jsgrid pagesnebulaGrid factory: row selection, mass actions, filters
nebula-rule-editor.jsprice-rule pagesStandalone rule conditions/actions tree
nebula-catalog-product.jscatalog product edit12 product-edit specific factories
nebula-bundle-options.jsbundle product editBundle option builder
nebula-custom-options.jsproduct custom optionsCustom option builder
nebula-customer-addresses.jscustomer editAddress book
nebula-pagebuilder.jspage builder fieldsPage Builder shim
nebula-vendor-chart.jsdashboardwindow.Chart (chart.js v4.4)
nebula-vendor-sortable.jspages with reorder UIwindow.Sortable (sortablejs v1.15)
Why so many bundles? Conditional loading — the catalog product page loads ~250KB of JS; the cache management page loads ~200KB (just core). Heavy pages don't pay for features they don't use. Cache reuses are huge: nebula-core hits every page, so it's warm on the second click.

Boot order

<head>
  <script src="nebula-core.js"></script>     ← Alpine attached, plugins installed,
                                              all factories register listeners on
                                              alpine:init, vendor compat installed,
                                              system-config bridges scheduled.
                                              Alpine.start() DEFERRED to DOMContentLoaded
                                              so sibling bundles can hook in first.
</head>
<body>
  ...
  <script src="nebula-form.js"></script>     ← nebulaForm factory registers listener
  <script src="nebula-grid.js"></script>     ← nebulaGrid factory registers listener
  <!-- DOMContentLoaded -->
  <!-- bootSystemConfig() runs: depends + test-connection + image-preview -->
  <!-- Alpine.start() fires alpine:init: every registered factory wires up -->
  <!-- Each x-data="..." component instantiates and calls init() -->
</body>

The deferred Alpine.start() matters: bundles loaded after nebula-core need a chance to register their factories before init fires. Without the defer, late bundles would miss the bus and their components would log "factory undefined".

Author your own factory

Concrete: a vendor module ships a custom pricing widget that needs to participate in the form save flow.

1

Write the factory in TypeScript

YourModule/web/ts/pricing-widget.ts
import type { NebulaFieldConfig, NebulaModel, SerializedPair } from '@qoliber/nebula/types';

interface PricingConfig extends NebulaFieldConfig {
  pricingRules?: Record<string, number>;
}

export function createPricingWidget(config: PricingConfig = {}): NebulaModel {
  return {
    fieldName: config.fieldName ?? '',
    value: config.value ?? 0,
    rules: config.pricingRules ?? {},

    init() {
      if (this.fieldName) {
        const store = window.Alpine?.store('nebulaModels');
        if (store) store.register('widget:' + this.fieldName, this);
      }
    },

    serialize(): SerializedPair[] {
      if (!this.fieldName) return [];
      return [{ name: this.fieldName, value: this.value }];
    },
  };
}

export function registerPricingWidget(): void {
  document.addEventListener('alpine:init', () => {
    window.Alpine.data('acmePricingWidget', (config?: PricingConfig) =>
      createPricingWidget(config),
    );
  });
}
2

Bundle entry point

YourModule/web/ts/bundle.ts
import { registerPricingWidget } from './pricing-widget';
registerPricingWidget();

Build it with esbuild however you like — your module owns its own build script. Output toview/adminhtml/web/js/acme-pricing.js.

3

Use it from a template

<div x-data="acmePricingWidget({
  fieldName: 'product[pricing]',
  pricingRules: <?= json_encode($rules) ?>
})">
  <input x-model="value" type="number" step="0.01">
</div>

<!-- Loaded as a regular admin script via layout XML or scripts.phtml -->
<script src="<?= $block->getViewFileUrl('Acme_Module::js/acme-pricing.js') ?>"></script>
4

Done

Your widget is now part of the standard save flow. The form's nebulaForm factory will call store.serializeAll() at submit time, your widget's serialize() will return its { name, value }, and Magento receives it in the POST body. Zero changes to the form save controller.

Nebula doesn't require you to use a build step or TypeScript for your factory. A handwritten JS file that calls window.Alpine.data('name', fn) works just as well — TS is a recommendation for type safety, not a hard dependency.

What gets tested

vitest unit tests (web/ts/__tests__/)

  • Model store: register, serialize, validate, destroy
  • Validator: every built-in rule + custom rules
  • Toast: create, dismiss, pause/resume
  • Grid: row selection, filter manipulation
  • Rule editor: tree build + serialization
  • Field factories where state is non-trivial
npm test          # run once
npm run test:watch # vitest watch mode

Manual / browser-side

  • End-to-end form save: fill fields, click save, check Magento receives correct POST
  • Validation UX: invalid input, blur, see error message
  • Toast appearance and timing
  • DOM-level Alpine bindings (these are Alpine's job, not ours)
  • Cross-browser select rendering, dark-mode contrast

Open the console and call Alpine.store('nebulaModels').debug() to see every registered model on the current page.

Build process

design/.../web/build.mjs (high level)
import * as esbuild from 'esbuild';

const bundles = [
  { entry: 'ts/nebula-core.ts',           out: 'js/dist/nebula-core.js' },
  { entry: 'ts/nebula-form.ts',           out: 'js/dist/nebula-form.js' },
  { entry: 'ts/nebula-grid.ts',           out: 'js/dist/nebula-grid.js' },
  // ... 8 more
];

await Promise.all(bundles.map(b => esbuild.build({
  entryPoints: [b.entry],
  outfile:     b.out,
  bundle:      true,
  format:      'iife',
  target:      'es2022',
  minify:      true,
  sourcemap:   process.env.SOURCEMAPS !== 'false',
})));

npm run build compiles all 11 bundles in parallel (~50ms each, all done in < 500ms).npm run watch:js rebuilds on change. SOURCEMAPS=false npm run build for production-grade output (smaller bundles, no .map files).

Why this matters

Stable contracts

The model store API is typed and tested. Any 3rd-party widget that implements the interface plugs in.

Familiar templates

Templates are still phtml + Alpine. No JSX, no React, no module loader. Edit a class and reload.

Versioned bundles

No more loose .min.js files in the repo. Vendors (Chart.js, Sortable) live in package.json and ship through esbuild like everything else.

Reference paths

  • design/adminhtml/Qoliber/Nebula/web/ts/nebula-core.ts (entry)
  • design/adminhtml/Qoliber/Nebula/web/ts/types.ts (public types)
  • design/adminhtml/Qoliber/Nebula/web/ts/models.ts (store + serialize contract)
  • design/adminhtml/Qoliber/Nebula/web/ts/validate.ts (validation rules)
  • design/adminhtml/Qoliber/Nebula/web/ts/fields/* (every field factory)
  • design/adminhtml/Qoliber/Nebula/web/ts/components/* (system-config bridges, modals)
  • design/adminhtml/Qoliber/Nebula/web/build.mjs (esbuild config)
  • design/adminhtml/Qoliber/Nebula/web/__tests__/* (vitest)