Skip to content

Frontend architecture: assets/ vs ui/

Canvas Builder deliberately ships two separate frontend stacks. The split is intentional — do not merge them, and do not import across the boundary.

The two stacks

assets/ ui/
What Editor core + style-panel component libraries Section toolbar app ("editor chrome")
Pattern Classic Drupal.behaviors, one directory per component App-style ES modules (state store, popovers, geometry) built by Vite
Loading Per-component Drupal libraries (canvas_builder.libraries.yml) Single committed IIFE bundle ui/dist/section-toolbar.js + section-toolbar.css, referenced by the section_toolbar library
Palette Light admin UI — --cb-* tokens Dark editor chrome — --cb-stb-* tokens (Radix-derived)
Source of truth Edited in place ui/src/section-toolbar/**never hand-edit ui/dist/*

A third, tiny surface: assets/frontend/

Not part of either stack's editor scope. assets/frontend/bg-media-visibility/ is the published-page runtime: it reads each media layer's data-cb-show-from / data-cb-hide-from viewport IDs, resolves their pixel widths from drupalSettings.canvasBuilder.viewports, and toggles data-cb-bg-hidden via window.matchMedia — no @media CSS, no PHP-built breakpoint grammar (CLAUDE.md §6). assets/admin/admin.css styles the settings pages. Neither imports from ui/ or from the editor components.

Why two stacks

  • The section toolbar is app-like: a small hand-rolled store (core/state.js), pure layout/redux lookups, a popover + geometry engine, and cross-viewport template logic. It benefits from module decomposition and a bundler.
  • The style panels are classic Drupal behaviors attached to AJAX-rendered forms; a bundler there would add indirection without benefit.
  • The ui/ workspace deliberately mirrors drupal/canvas's own ui/ convention (own package.json + vite.config.js, @/ alias) — not the repo's ImageX swat/turbo workspace. See the header comment in ui/vite.config.js.

Build

cd ui && npm install && npm run build
  • Output is a classic IIFE, not type: module — the toolbar registers a Drupal.behaviors entry that must exist before Drupal's initial attachBehaviors runs; a deferred module script could register too late.
  • ui/dist/ is committed (readable output + sourcemap, minify off); ui/node_modules/ is not.

ui/src/section-toolbar/ internal layering

Directories under the single index.js entry point, in dependency order (each layer may import the ones before it, never the reverse):

Layer Contains Examples
core/ Toolbar-agnostic primitives: DOM/geometry helpers, the popover engine, the shared dismissal manager, motion constants, editor-pane ops, the hand-rolled state store. geometry.js, popover.js, overlay-manager.js, canvas-pane.js, dom-utils.js, state.js, viewport-labels.js
layout/ Column/template domain logic: the Redux tree-walk helpers, template composition, family/responsive-prop resolution, section store-ops. Pure functions where possible (see ui/src/**/*.spec.js). columns.js, compose.js, templates.js, redux.js, sections.js
app/ Cross-cutting runtime infrastructure the toolbar's many moving parts share. scheduler.js, async-wait.js
features/ Vertical feature slices — each a directory with an index.js barrel as its public API. Cross-feature imports go through the barrel only, never a feature's internals. layout-chooser/ (chooser.js + picker-card.js), insertion/ (insert-section.js + boundaries.js + inserter-ui.js), popovers/ (advanced.js + layout.js + width.js + height.js + panels.js), toolbar/ (mount.js + skeleton.js + review-panel.js), hidden-children/ (badge.js + detect.js + peek.js)

app/scheduler.js is a single shared requestAnimationFrame loop (addJob({measure, mutate})) that replaced four independent per-surface rAF chains (toolbar sync, layout chooser, boundary inserter, toolbar shell). Every job's measure() runs before any job's mutate() in a given frame (structurally prevents read-after-write layout thrashing), geometry reads are memoized per-frame, and the loop idle-sleeps after 10 consecutive non-dirty frames (wake() on scroll/resize/mousemove/store-change restarts it).

core/overlay-manager.js is a single shared document click+keydown listener pair (registerSurface({el, wants, onDismiss})) that replaced three independent per-surface listener sets (toolbar, layout chooser, inserter card). Outside-click ownership is chrome-wide — a click inside ANY registered surface counts as "inside" for every surface, not just the one it landed in — and Escape dismisses newest-registered-first.

Both are module-internal infrastructure, not part of the client API — they are deliberately absent from docs/EVENTS.md's cb:* event contract and the Drupal.canvasBuilder namespace. Nothing outside ui/src/section-toolbar/ should import or depend on them directly.

Boundary rules

  1. No imports across the boundary. ui/src and assets/ never import each other's files.
  2. Communication happens only through the documented event contract (docs/EVENTS.md), drupalSettings, and the shared Drupal.canvasBuilder namespace owned by assets/core/core.js.
  3. Geometry/positioning code exists on both sides on purpose (panel widgets vs toolbar popovers). It is not duplication to unify.
  4. Palettes stay separate: --cb-* (light admin) vs --cb-stb-* (dark chrome). Shared brand values (e.g. the canvas accent blue) may be referenced from both, but the token sets are not merged.

Shared helpers (assets side)

assets/core/core.js is the always-loaded foundation and owns cross-component utilities (Drupal.canvasBuilder.getPreviewDocuments, parseJsonAttr / parseJsonAttrCached, the ghost-change defences). Components must use these rather than re-implementing them.

Audit follow-ups (2026-07) — completed, no functional change:

  • Local getPreviewDoc() clones (box-model.js, color-swatch.js) folded into Drupal.canvasBuilder.getPreviewDocument() — the themed-preview singular, distinct from getPreviewDocuments() (which includes the admin document).
  • The duplicated ensurePreviewCss(doc) guard mechanics were hoisted into Drupal.canvasBuilder.injectPreviewCss(doc, id, cssText). The two CSS payloads were never duplicates — bg-media's full media+overlay block vs overlay's standalone fallback (which defers to bg-media's) — and stay with their owning components.
  • Local parseJsonAttr(value) copies (layout-widgets/widgets.js, color-swatch.js) replaced with core's parseJsonAttr(el, attr, fallback).
  • Icon injection unified on Drupal.canvasBuilder.svgIcon(path, size) — the single icon innerHTML sink (background, layout-widgets, prop-groups, device-picker, text-align). Security invariant: icon path data comes only from module-defined maps delivered via drupalSettings.canvasBuilder.icons (see src/Icons.php) or equivalent local constants — never from user or content-derived strings. Two icon systems intentionally stay local: the fully static lock-badge SVG constants in layout-widgets / device-picker (interpolate nothing), and box-model's stroke-drawn svg(name, size) (its own class hook + stroke attributes — a different rendering idiom, still fed only by its module-defined ICON map).