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 ownui/convention (ownpackage.json+vite.config.js,@/alias) — not the repo's ImageX swat/turbo workspace. See the header comment inui/vite.config.js.
Build¶
cd ui && npm install && npm run build
- Output is a classic IIFE, not
type: module— the toolbar registers aDrupal.behaviorsentry that must exist before Drupal's initialattachBehaviorsruns; 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¶
- No imports across the boundary.
ui/srcandassets/never import each other's files. - Communication happens only through the documented event contract
(
docs/EVENTS.md),drupalSettings, and the sharedDrupal.canvasBuildernamespace owned byassets/core/core.js. - Geometry/positioning code exists on both sides on purpose (panel widgets vs toolbar popovers). It is not duplication to unify.
- 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 intoDrupal.canvasBuilder.getPreviewDocument()— the themed-preview singular, distinct fromgetPreviewDocuments()(which includes the admin document). - The duplicated
ensurePreviewCss(doc)guard mechanics were hoisted intoDrupal.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'sparseJsonAttr(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 viadrupalSettings.canvasBuilder.icons(seesrc/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-drawnsvg(name, size)(its own class hook + stroke attributes — a different rendering idiom, still fed only by its module-definedICONmap).