Architecture¶
How a style is stored, resolved, and rendered.
Overview¶
How a style is stored, resolved, and rendered. For the invariants, the gotchas, and the history behind them, see CLAUDE.md at the repository root — the internal engineering guide this page is distilled from.
Where styles live¶
A component's style bundle ({classes, media, bgActiveTab}, keyed by component UUID) exists in two places that are not interchangeable:
- Authoritative — the
canvas_builder_stylesbase field on thecanvas_pageentity. Classes are stored as per-viewport maps ({mobile: 'p-0', desktop: 'p-lg-0'}). Canvas's auto-save draft is forked from this field; every editing read and write goes here. - Derived — Drupal State, written only on publish and read only by the front-end renderer. Flattened class-union strings, plus a few per-viewport maps for keys that must resolve per breakpoint at render time (overlays, background color, the active background-type tab).
flowchart LR
A[Editor changes a style control] --> B[Draft: canvas_builder_styles field]
B -- publish --> C[Drupal State]
B -- read for editor preview --> D[Editor canvas]
C -- read for front end --> E[Published page]
Reading the flattened State store when you need a per-viewport value is the single most common style bug in this codebase — per-viewport detail is lost there by design.
The style plugin system¶
A style definition (Background, Spacing, Typography, or a contrib addition) is a YAML-shaped array with a type that dispatches to a StyleInterface handler (select, media, group, or a custom type). Definitions merge from three sources, in order: the module's own canvas_builder.plugins.yml, every enabled module's hook_canvas_builder_styles() return value, and every enabled module or theme's {provider}.canvas_builder_styles.yml file — then hook_canvas_builder_styles_alter() runs last, on top of everything. See Extending for the full mechanics and worked examples.
flowchart TD
P1[canvas_builder.plugins.yml] --> M[Merged definitions]
P2[hook_canvas_builder_styles per module] --> M
P3["{provider}.canvas_builder_styles.yml per module/theme"] --> M
M --> A[hook_canvas_builder_styles_alter]
A --> F[StyleManager attaches the Style tab]
F --> G[Each control dispatches to its type handler: select / media / group / custom]
Persist → render pipeline¶
- Build.
hook_form_component_instance_form_altercallsStyleManager::attachStyles(), which builds one form element per enabled definition, each carrying a#canvas_styleproperty naming its storage kind and key. - Persist. An AJAX-triggered
#element_validatecallback (StyleManager::persistElement) resolves the active viewport and writes the submitted value into the auto-save draft via Canvas's ownAutoSaveManager::saveEntity(). A value equal to its declared default is not stored, so the field only holds real overrides. - Preview. The editor reads the draft (with a per-request cache) and paints the live preview from
data-cb-valuesper-viewport data attributes — never from the currently displayed control value, which can be stale until a viewport switch re-syncs it. - Publish. Saving the
canvas_pageentity flattens the draft into Drupal State and invalidates the component's cache tag. - Render. The front-end render callback (
CanvasBuilderTrustedCallbacks) reads State, splits any multi-viewport union string into individual sanitized class tokens, and emits them on the component wrapper — plus one background/overlay<div>per contiguous visibility window, for cases where an image or overlay differs across breakpoints.
sequenceDiagram
participant Editor
participant Form as StyleManager
participant Draft as Auto-save draft
participant State as Drupal State
participant Page as Published page
Editor->>Form: change a style control
Form->>Draft: persistElement() writes per-viewport value
Draft-->>Editor: live preview repaints from data-cb-values
Editor->>Draft: publish the page
Draft->>State: flatten + write derived stores
State->>Page: render wrapper classes + media layers
Framework profile swap¶
A profile is a snapshot of the whole class-config surface (every style table, layout_maps, the box-model side→letter map) plus a render engine id (css_vars for the default Custom profile, classes for Bootstrap 5 and Tailwind). Switching profiles is non-destructive: the current live config is auto-snapshotted back into the outgoing profile before the target profile's snapshot loads. The three shipped profiles' data lives in src/Service/ProfileData/*.yml — code, not config, so it's never in drush cex output; a user-saved profile lives in canvas_builder.settings config instead.
flowchart LR
Live[Live class config] -- auto-snapshot --> Outgoing[Outgoing profile]
Shipped["src/Service/ProfileData/{id}.yml"] -- or --> Target[Target profile snapshot]
UserSaved["canvas_builder.settings (user profile)"] -- or --> Target
Target -- load --> Live
Section and Column layout (count, gap, alignment, height) always renders through CSS Grid, independent of the active engine — a framework's own utility classes ride alongside that layout, they never replace it. See Frameworks.
Where to go deeper¶
- Extending, Custom style types, and Prop groups & widgets — every extension point, worked examples, the full definition schema.
- Frameworks — layout class maps, the Tailwind safelist, profile export/import.
- Motion engine architecture — the animation connector contract, engine registration.
- Events — the client JS API and
cb:*event contract.