Page Builder — Developer Reference

The Page Builder is a full-screen React + Zustand layout editor that runs as a frontend screen (opened via a launcher button in a WordPress meta box on the classic post-edit screen — see Entry Point and Data Bootstrap below for why it isn't mounted in wp-admin itself). It lets editors compose page content using a recursive tree of divs and widgets — a div can hold widgets, further divs, or a mix of both, at any depth, with no fixed number of levels. Everything is serialized to JSON and saved in the sanilwb_data post meta key. On the frontend, PHP recurses over that same tree and renders each div as a flex container with [sanilwb] shortcodes for its widgets.

Naming note: the codebase (node kind, actions like addDiv(), components like CanvasDiv.jsx/DivLayerBar.jsx, DIV_DIALOG_TABS) uses "div" throughout. The UI shows this same node type to editors as "Container" (see the Widget Picker's Container card and the Layers Panel bar label) — this doc uses "div" when referring to code/data and "Container" only when describing what the editor sees.


Architecture Overview

PageBuilder.jsx                ← root component, keyboard shortcuts
├── BuilderTopbar               ← shared topbar shell (left/center/right slots)
│   ├── ActionsDropdown (☰)       ← Show Layers / Show History / Exit
│   ├── device toggles
│   ├── Canvas Settings button    ← opens CanvasSettingsSidebar
│   ├── Preview button
│   └── SaveButton                ← save state machine, AJAX POST
├── NoticeBar                    ← queued dismissible notices (useNoticeStore)
├── LayersPanel                 ← all editing actions (add, move, delete, settings)
├── HistoryPanel                 ← undo/redo timeline viewer
├── PreviewPanel
│   └── IframeCanvas             ← canvas React root mounted inside an iframe
│       └── Canvas                ← reads store, renders live layout (CanvasDiv, recursive)
├── PageBuilderSettingsSidebar  ← right-side settings panel (div / widget)
├── SaveAsTemplateDialog        ← "Save as Template" panel for a div's own subtree (opened from its layer-bar menu)
└── CanvasSettingsSidebar       ← header/footer visibility toggles + Preview Post/Term picker, every context

All layout state lives in usePageBuilderStore (Zustand). Both the main document (LayersPanel) and the iframe (Canvas) import the same store module from the same JS bundle, so they share a single store instance — no props or context needed between the two React roots.

Note: there is no longer a separate topbar "Close" button — Exit lives inside the hamburger (☰) ActionsDropdown menu alongside Show Layers/Show History.


Entry Point and Data Bootstrap

The full-screen editor is a frontend screen, not a wp-admin page — this avoids every wp-admin screen's Command Palette, which conflicted with the editor. The classic post-edit screen's metabox is reduced to a small launcher.

File Purpose
admin/assets/js/src/page-builder/index.jsx React app entry — mounts <PageBuilder /> into #sanilwb-pb-root
public/hooks.php template_redirect handler for /?sanilwb_editor=1&post_id={id} (the real full-screen editor for pages/Site Layout entries) and for /?sanilwb_template_editor=1&id={id}&builder_type=template (templates — see Template Editing below)
admin/templates/page-builder/editor-page.php Rendered by the ?sanilwb_editor=1 route — injects window.PageBuilderData and the #sanilwb-pb-root mount div
admin/templates/page-builder/template-editor-page.php Same, for the ?sanilwb_template_editor=1 route
admin/class-sanilwb-admin-page-builder.php Class SANILWB_Admin_PageBuilderbuild_window_data() builds window.PageBuilderData, shared between the frontend route and the classic metabox. meta_box_html() registers the metabox itself (now just a launcher, see below); ajax_pb_save() handles the sanilwb_pb_save AJAX action
admin/templates/meta-box-contents.php The classic post-edit-screen metabox's own content — a hidden sanilwb_data input (so a normal WP save doesn't clear the layout) plus an "Open Page Builder" button linking to the ?sanilwb_editor=1 route. Does not mount the React app itself.

window.PageBuilderData shape

SANILWB_Admin_PageBuilder::build_window_data() returns this object (see the method's own docblock in admin/class-sanilwb-admin-page-builder.php for the full inline rationale behind each key):

{
  postId:             number,
  postTitle:          string,
  postEditUrl:        string,
  previewUrl:         string,   // live frontend URL for the topbar's Preview button
  isSiteLayout:       boolean,  // true when this document is a Site Layout entry, not a regular Page
  layoutType:         string,   // Site Layout's assigned type ('single'|'page'|'archive'|'404'|'search'), '' for a regular Page
  ajaxUrl:            string,
  builderData:        string,  // saved sanilwb_data post meta (JSON string or '[]')
  categories:         [{ id, name }],
  postTypes:          [{ ... }],  // for Loop's Post Type field
  availableTemplates: [{ id, name, builder_type, category, supports }],
  templateCategories: object,
  imageSizes:         object,  // wp_get_registered_image_subsizes(), for Image/Featured Image size dropdowns
  pbRenderNonce:      string,  // nonce for sanilwb_pb_render_widget AJAX action
  pbSaveNonce:        string,  // nonce for sanilwb_pb_save AJAX action
  templatesNonce:     string,  // nonce shared with SANILWB_Ajax actions (post/term preview, Loop preview, etc.)
  themeUrl:           string,
  pluginUrl:          string,
  themeColors:        object,
  themeVariables:     object,
  headerVisible:      boolean, // canvas's header/footer preview region toggles (Canvas Settings panel)
  footerVisible:      boolean,
}

On mount, initFromWindowData() reads builderData, parses it from PHP format, and seeds the store's sections array (the array is still named sections for historical reasons — it holds root-level divs, not the old "Section" node type). Everything else in PageBuilderData is read lazily (e.g. categories and availableTemplates are read when a widget dialog opens).


Template Editing (context)

Page Builder is also the editor for templates — there is no separate editor app for them, and no separate document type either (see Dynamic Template Widget below). window.PageBuilderData.context is 'page' (the default) or 'template', set by SANILWB_Admin_PageBuilder::build_template_window_data() for the frontend route ?sanilwb_template_editor=1&id={template_id}&builder_type=template (handled in public/hooks.php). In template context, builderData holds a wp_sanilwb_templates.schema_json row instead of post meta — same kind: 'div'|'widget' tree shape either way.

Each widget definition carries a contexts: [...] array restricting where it can be added. WidgetPickerDialog.jsx filters the Add Widget grid by contexts.includes(context). There are three possible contexts — page, template, and site_layout (see Site Layout). Data-fetching/page-scoped widgets (post_content, shortcodes, archive_title, term_name, template_reference) allow ['page', 'site_layout'] but not template — a template is reused across many different pages, so a live query, a page-level archive label, or a link to yet another template wouldn't be meaningful there; a Site Layout, unlike a template, renders for exactly one real request (a single/archive/404/search page), the same as a Page does, so it gets the same widgets a Page does. Purely static/layout widgets (heading, button, image, paragraph, icon, social_icons, video, carousel_nav) and widgets that read the ambient post (post_title, featured_image, excerpt, author, date) allow all three (['page', 'template', 'site_layout']) — these resolve correctly inside a template too, since a template rendered live (directly, or through a Dynamic Template reference) still inherits whatever post is ambient at render time.

Server-side backstop: SANILWB_Ajax::WIDGET_CONTEXTS (includes/class-sanilwb-ajax.php) is a widget_type => allowed contexts map mirroring every widget's own contexts array, checked by schema_has_content_not_allowed_in_context(), which rejects a template save (sanilwb_template_save) or a site_layout save (sanilwb_site_layout_save) if the schema contains a not-allowed-here widget type anywhere in the tree, recursing into every div's children[]. WidgetPickerDialog.jsx already keeps a user from adding one of these through the UI, but this check is what actually prevents a schema containing one from ever being saved — keep the two in sync when a widget's contexts array changes.

SaveButton.jsx branches on context: page posts to sanilwb_pb_save (post meta); template posts to sanilwb_template_save (a wp_sanilwb_templates row). There is no compile step anymore — a template's schema_json is rendered live wherever it's referenced, the same way a page renders its own content (see Dynamic Template Widget below).

Name field. A template has no WP post title of its own — its name is edited directly in the topbar (the input rendered in place of the post-title <span> when isTemplateMode is true, see PageBuilder.jsx). For a brand-new, unsaved row (postId is 0), build_template_window_data() auto-fills name with the readable default "New Template", run through SANILWB_DB::unique_name() so two new templates opened back-to-back don't collide ("New Template", "New Template (2)", ...). This dedup only applies to the auto-generated default — a name the user actually types is never checked against existing rows. SaveButton.jsx disables the Save button (with a tooltip) whenever the trimmed name is empty, so a template can't be saved nameless; SANILWB_Ajax::save_template() rejects an empty name server-side too, as the real boundary check.


Dynamic Template Widget

Files: admin/assets/js/src/page-builder/config/widgets/templateReference/templateReference.js · admin/assets/js/src/page-builder/config/widgets/templateReference/templateReference.styles.json · admin/assets/js/src/page-builder/components/SaveAsTemplateDialog.jsx · admin/assets/js/src/page-builder/stores/pageBuilder/actions.js (replaceNodeWithSavedTemplateReference()) · admin/assets/js/src/shared/components/fields/TemplateContentOverridesField.jsx · admin/assets/js/src/shared/hooks/useWpEditor.js · includes/shortcodes/class-sanilwb-template-reference-shortcode.php · includes/class-sanilwb-frontend-renderer.php (get_instance(), render_nodes(), get_uid_scope_prefix()/set_uid_scope_prefix(), get_active_overrides()/set_active_overrides(), get_template_el_counter()/set_template_el_counter(), get_active_template_id_for_css()/set_active_template_id_for_css()) · includes/class-sanilwb-css-cache.php

The widget type key is template_reference; its display label is "Dynamic Template" (only the label — internal identifiers keep the original name). It's a live pointer to a saved wp_sanilwb_templates row. Its Content tab has two sections: Template (the sanilwb_template_id <select> of every available template, plus a link-button "Edit Template" that opens the referenced template in its own editor in a new tab once one is picked) and Overrides (per-instance content overrides for that template's own Heading/Paragraph/Button widgets — see Per-Instance Content Overrides below). It is ['page', 'site_layout']-only (a template can't reference another template through this widget, avoiding an authoring-time cycle — a Site Layout is not itself a template, so this restriction doesn't apply to it).

"Save as Template" creates one of these automatically. Right-clicking a container in the Layers Panel and choosing "Save as Template" opens SaveAsTemplateDialog.jsx, which POSTs the container's own subtree to sanilwb_template_save as a brand-new wp_sanilwb_templates row. A checkbox, "Replace this container with a reference to the new template" (default checked), then calls replaceNodeWithSavedTemplateReference(nodeId, newTemplateId) — this swaps the original container on the page for a new template_reference widget pointing at the row just created, so the page stays live-linked to it instead of holding a frozen, disconnected copy. Unchecking it just saves the template without touching the page, for the rarer case of deliberately forking a one-off copy.

HTML rendering is always live — there is no compiled HTML artifact. SANILWB_Template_Reference_Shortcode::render() fetches the referenced row (SANILWB_DB::get(), memoized per request per template id — $row_cache), json_decode()s its schema_json, and renders it through SANILWB_Frontend_Renderer::render_nodes() — the exact same tree-walking method a real page's own content renders through, not a separate code path. This means editing a template updates every place it's referenced immediately, with no stale-cache window, at the cost of a full HTML re-render of that template's tree on every single page load for every reference (an explicit, discussed tradeoff — see the "why not a compiled file" reasoning below). CSS is a different story — see CSS Caching: each Template's own CSS is a real static file, generated once at save time and shared across every placement, so the per-reference cost today is HTML tree-walking only, not CSS building too.

Renderer instance reuse — why get_instance() exists. SANILWB_Frontend_Renderer self-registers the most recently constructed instance (self::$instance = $this in its constructor) so the shortcode — dispatched through the separate SANILWB_Shortcode_Handler object, with no reference back to whichever renderer is already mid-page-render — can reach it via SANILWB_Frontend_Renderer::get_instance() instead of constructing a fresh one. Exactly one instance is ever constructed per request, so this is safe. Reusing the live instance keeps $el_counter (the running counter behind every sanilwb-el-N scoped CSS class, on a real render used purely to keep HTML class names unique/continuous — see CSS Caching for why CSS itself is no longer built this way) continuous instead of restarting at zero, which would otherwise mint the exact same class names the outer page already used. Only when nothing is rendering yet (e.g. the editor's isolated AJAX widget-preview call, or SANILWB_CSS_Cache's own generation pass, which deliberately always uses a fresh instance) does it fall back to new SANILWB_Frontend_Renderer().

Renderer-state snapshot/restore. render() snapshots four pieces of renderer state before walking the template's nodes and restores them afterward: uid_scope_prefix/active_overrides (see below), plus template_el_counter/active_template_id_for_css (reset to (0, $tmpl_id) for the duration — see CSS Caching for why this makes the same Template's own div class names identical regardless of placement or nesting). A pending_css snapshot/restore is also still taken — on a real render it's a no-op (CSS collection is always suppressed there), but during SANILWB_CSS_Cache's generation pass for an outer Template that itself references this one, it prevents this nested Template's CSS from leaking into the outer Template's own generated file (each Template's CSS is cached and regenerated independently).

Recursion guard. A per-request static array ($currently_rendering_ids) tracks which template ids are currently mid-render; a template that would reference itself (directly, or through a cycle) returns '' instead of recursing infinitely. The editor UI can't create this on its own — template_reference's contexts: ['page', 'site_layout'] means it can never be placed while authoring a template in the first place — but a directly-edited schema_json (e.g. via WP-CLI) could still form one, so the guard exists as a cheap backstop regardless.

Per-placement uid scoping. A template's own nested widgets each keep the fixed widget_uid they were assigned at authoring time, frozen into schema_json — placing the same template more than once on one page would otherwise render every placement's copy of e.g. its Heading with the identical data-uid/.sanilwb-widget-{uid} class, colliding on both DOM identity and CSS selectors. SANILWB_Frontend_Renderer::get_uid_scope_prefix()/set_uid_scope_prefix() fixes this: render() snapshots the renderer's current prefix, sets it to this placement's own shortcode_uid (already attached to every widget's shortcode call by render_widgets(), reused here rather than invented) before calling render_nodes(), then restores the snapshot afterward — the same shape as the CSS snapshot/restore above. Inside render_widgets(), a nested widget's uid becomes {prefix}--{raw_uid} whenever a prefix is active, so two placements of the same template produce two distinct uids for what's structurally the same Heading. A template_reference nested inside another template chains the prefix one level deeper automatically, since the scoped uid is what gets passed on as the next level's own shortcode_uid.

Why not a compiled PHP file for HTML too, and why not a transient cache either: both were weighed and rejected, for HTML specifically. A compiled file needs a recompile step kept in sync with every save, adds a second artifact that can drift from the source schema_json, and (as actually happened) breaks the moment it's asked to render something outside its original narrow use case. A transient cache would reintroduce a staleness window — the entire point of this feature is that editing a template shows up everywhere it's referenced immediately. For a page repeating one template 20-30 times (e.g. a magazine-style card grid), this means paying the HTML render cost on every reference, every page load, by design — flagged here as a known, accepted cost rather than an oversight. This reasoning does not extend to CSS (see CSS Caching): a Template's CSS is a pure, deterministic function of its own saved schema_json (no ambient post data ever feeds into it), regenerated automatically from the exact same collect_*_css() calls on every save — so caching it to a file carries none of the "second artifact drifting from the source" risk a hand-maintained compiled PHP file would, and the "editing shows up immediately" guarantee still holds since generation is re-triggered on every save, not on a stale timer.

Supports styling, Carousel, and Loop like any other widget, since it renders inside the parent container's normal render pass — a Loop-enabled div containing a Dynamic Template reference resolves that reference's own ambient widgets (Post Title, Excerpt, Featured Image, Author, Date) to the current loop iteration's actual post, the same as any other widget placed directly in the loop body, because the widget has no post-context logic of its own — it transparently inherits whatever post is already ambient when it renders. Archive Title is the one exception: it reads get_the_archive_title() (a page-level "which archive is being viewed" value, not a per-post one), so it stays constant across every Loop iteration rather than varying per item — expected behavior for what that widget represents, not a bug specific to Dynamic Template.

Style tab is intentionally reduced, not the full shared scheme. templateReference.styles.json's useSharedSections lists only Border, Box Shadow, Spacing, and Position — Layout, Size, Background, and Gradient were removed outright (judged not meaningful for this widget: its wrapper holds one already-styled template as its only "child," so Layout's flex-arrangement fields have nothing of substance to arrange, and a second background/gradient sitting behind a whole separate template's own design reads as redundant rather than useful). No omitFields/own-tag redirection involved, unlike Button/Carousel Nav (see below) — this is simply a smaller useSharedSections list than the full scheme every non-migrated widget still gets (see SHARED_STYLE_FIELDS below).

Per-Instance Content Overrides

Lets one placement of a referenced template override just its Heading/Paragraph/Button text (and, where the widget type supports one, its link URL) — without touching the template's own saved content, and without needing a separate "presets" system. Built to solve a concrete case: a news homepage reusing one designed section block many times, with a different heading and button link each time, without duplicating the whole layout by hand per section. A field left blank keeps live-inheriting from the template; a filled-in field overrides it for that one placement only.

Storage. One extra widget_values key on the Dynamic Template widget itself, sanilwb_template_overrides — a JSON object keyed by the nested widget's own raw widget_uid (as it sits inside the referenced template's schema_json), each value an object of field_name: value pairs (e.g. {"abc123": {"sanilwb_text": "…", "sanilwb_link_url": "…"}}). Same JSON-string-in-a-flat-field convention widget_values already uses everywhere else in this plugin.

Reaching PHP needed no new plumbing. render_widgets()'s existing shortcode-attribute loop already base64-encodes and forwards every key present in a widget's values as a shortcode attribute — sanilwb_template_overrides reaches SANILWB_Template_Reference_Shortcode::render() as a template_overrides attribute automatically, on both the real frontend and the editor's AJAX preview path, with no changes to that loop. render() decodes it and calls SANILWB_Frontend_Renderer::get_active_overrides()/set_active_overrides() around render_nodes() (same snapshot/restore shape as the CSS and uid-prefix handling above). Inside render_widgets(), right after a widget's widget_values is decoded, a matching override (looked up by that widget's own raw widget_uid) is merged onto the decoded values object before anything reads it — so every downstream consumer (shortcode attribute building, CSS collection) sees the overridden value with no per-widget-type special-casing.

Content tab UI — TemplateContentOverridesField.jsx. Once a template is picked, this one field fetches that template's own tree (sanilwb_template_get, cached for the session — see the staleness note below), walks it for Heading/Paragraph/Button widgets at any depth, and renders one row per match labeled with that widget's own admin label, with a Text input (and a URL input, where the widget type supports one) underneath. Button's URL input is only shown when that button isn't set to "Link to Topic" instead. Paragraph's Text input is a real WordPress visual editor (TinyMCE), not a plain textarea — see below.

Shared useWpEditor() hook. The WP visual editor mount/lifecycle logic (wp.editor.initialize(), TinyMCE + Quicktags event wiring, toolbar restoration) was extracted out of WysiwygField.jsx into admin/assets/js/src/shared/hooks/useWpEditor.js so it can be reused here — WysiwygField.jsx itself is unchanged behaviorally, just delegates to the hook now. The reason a new hook was needed rather than reusing WysiwygField directly: that field reads/writes a value by field name straight into the dialog's shared value buffer, but every override lives nested inside this one field's own JSON value instead — useWpEditor() takes a plain editorId + onChange callback instead, with no buffer coupling. It uses a ref internally so the change/input listeners (registered once, at mount) always call whichever onChange was passed on the most recent render — necessary since each override row's onChange closes over that row's own widget id and is a fresh function every render.

Widget allowlist and field-name mapping live in OVERRIDABLE_WIDGET_FIELDS in TemplateContentOverridesField.jsx: Heading (sanilwb_text + sanilwb_link_url), Paragraph (sanilwb_text only, rich text), Button (sanilwb_label + sanilwb_url, gated by sanilwb_link_to_topic). Deliberately scoped to Phase 1 — Image/Icon overrides and per-instance style/color overrides are out of scope for now (style overrides would additionally need real per-placement CSS generation, which doesn't exist for any style field today).

Orphaned overrides are silently ignored, by design. Overrides are keyed by the nested widget's widget_uid. If the referenced template is later restructured so that uid disappears (the widget deleted and re-added, getting a new uid), the stored override for it just stops applying — no error, no migration UI, no warning. Matches this widget's existing content trust model (a page always defers to whatever the template currently contains).

Known limitations, not yet fixed — see DYNAMIC_TEMPLATE_OVERRIDES_REVIEW.md in the plugin root for the full review: the renderer-state snapshot/restore (uid prefix, active overrides, pending CSS) isn't protected against an exception thrown mid-render_nodes(), which could leak one placement's overrides onto other widgets rendered afterward on the same page; the PHP override merge has no field-name allowlist (trusts the JS UI to only ever send the four expected field names); the Overrides list's cached template fetch doesn't invalidate after editing the template via the widget's own "Edit Template" link, so it can go stale within a session; clearing Paragraph's rich-text override by selecting-and-deleting (rather than the field's own Reset button) may not report a literal empty string to TinyMCE, and so may not correctly fall back to the template's own text.


Data Structure

The Page Builder stores layout as a flat array of root-level divs, each recursing into a children[] array with no depth limit. There is one structural node type — a div — which can hold either widgets or further divs (or a mix of both) as children.

sections[]                                    ← top-level array; each entry is a root div
  └── div (kind: 'div')
        ├── id
        ├── options       { sanilwb_admin_label, sanilwb_bck_color, layout fields, ... }
        └── children[]    ← ordered mix of widgets and further divs, any depth
              ├── { kind: 'widget', id, uid, type, values }
              └── { kind: 'div', id, options, children[] }   ← recurses

Key rules: - A div's children[] can hold any combination of widgets and further divs, in any order, at any depth. There is no either/or constraint and no separate "nested row" concept. - A div's options carries its layout (flex direction/align/justify/wrap/gap via the shared Layout section), appearance (background, border, etc.), and visibility fields. - Widget values is a flat object of sanilwb_* key-value pairs. - Responsive fields are stored with device suffixes: fieldName, fieldName__tablet, fieldName__mobile.

PHP storage format

serializeToJson() (in stores/pageBuilder/serialization.js) converts the internal tree to a PHP-compatible JSON array saved in sanilwb_data:

[{
  "kind": "div",
  "options": {
    "sanilwb_container_style": "default",
    "sanilwb_show_desktop": "1",
    "sanilwb_show_tablet": "1",
    "sanilwb_show_mobile": "1"
  },
  "children": [
    {
      "kind": "widget",
      "widget_uid": "abc123",
      "widget_type": "template_reference",
      "widget_values": "{\"sanilwb_type\":\"template_reference\",\"sanilwb_template_id\":\"3\"}",
      "widget_title": "Dynamic Template 1",
      "show_desktop": "1",
      "show_tablet": "1",
      "show_mobile": "1"
    },
    {
      "kind": "div",
      "options": {},
      "children": []
    }
  ]
}]

widget_values is a JSON string — PHP decodes it independently with json_decode(). The widget_uid is stable across saves (not regenerated). The internal id is ephemeral (regenerated on every fromPhpFormat() call). A widget's own show_desktop/show_tablet/show_mobile are read from widget_values.sanilwb_show_* and duplicated onto the serialized widget node so PHP can read them without decoding widget_values first. Each child carries a kind discriminator ("widget" or "div") so both the JS deserializer and the PHP renderer can recurse without any other type field.


Store — usePageBuilderStore

File: admin/assets/js/src/page-builder/stores/usePageBuilderStore.js

This is the single source of truth for all layout state, composed from two slices — createHistorySlice (stores/pageBuilder/history.js) and createActionsSlice (stores/pageBuilder/actions.js) — plus settings-sidebar/selection/collapse state defined directly on the store. Every action that mutates state calls snapshot(label) first to push the current state onto the undo stack.

State

Key Type Purpose
sections DivNode[] The live layout tree — root-level divs (name is historical; holds divs, not the old Section type)
past { label, sections }[] Undo stack (capped at 50)
future { label, sections }[] Redo stack
activeSettingsTarget { type, id } \| null The item currently open in the settings sidebar. type is 'div' or 'widget'.
settingsPanelIsNew boolean True when the panel was opened for a brand-new uncommitted div or widget — on close without ever editing it, it is removed with no undo snapshot
hasUnsavedChanges boolean Set to true by every mutating action (via snapshot), reset to false by markSaved() after a successful save
selectedItemId string\|null ID of whichever item (div or widget) is the current selection — set by a canvas click, a layers panel click, or opening the settings panel for an item. Layers panel and canvas both read this to highlight the matching item; IframeCanvas subscribes to it to scroll the matching canvas element into view
hoveredLayerId string\|null Layer item currently hovered in the panel — the canvas draws a highlight outline on the matching element
canvasDocument Document\|null The iframe's document, set once IframeCanvas mounts its React root — WidgetPickerDialog always portals into this document instead of the outer admin document
collapsedLayerIds { [id]: true } Opt-out collapse map for every collapsible div bar. Absent key = expanded.

Actions

History

Action Description
snapshot(label) Saves current sections to past[] before any mutation. Clears future[]. Flushes any pending debounced edit first (see below) so it is not lost or merged into the wrong entry.
undo() Flushes any pending debounced edit, then pops past[], pushes current state to future[], restores the popped state
redo() Flushes any pending debounced edit, then pops future[], pushes current state to past[], restores the popped state
jumpToHistory(targetIndex) Flushes any pending debounced edit, then computes the equivalent past/future arrays for an arbitrary jump in a single set() call — not a loop of undo()/redo() calls, which would re-render every subscriber once per step. Used by the History panel's click-to-jump feature.
cancelDebouncedSnapshot(itemType, itemId) Discards (rather than commits) a pending debounced edit session for the given item, restoring hasUnsavedChanges to its pre-burst value. A no-op unless the pending session matches itemType/itemId. Called by removeNewWidget()/removeNewDiv() so cancelling a brand-new item can't leave a stale timer that later pushes a history entry (and lets Undo bring the item back) after it's already gone.

Debounced edit snapshots: field-level edits (updateDivOptions, updateWidget) call scheduleDebouncedSnapshot(itemType, itemId) instead of snapshot(label) directly. The pre-edit state is captured once when the burst starts, and the history entry is only pushed to past[] after a 500ms pause in edits — so typing in a settings field collapses into a single undo entry instead of one per keystroke. A single module-scope session object ({ timer, type, id, state, hadUnsavedChanges }) is shared across both actions (only one settings panel can be open at a time), keyed by type + id rather than a label — so switching to a different item mid-edit flushes the in-progress burst as its own entry instead of merging it with the new one.

The label itself is computed when the burst commits, by diffing the item's field values from before the burst against after:

Diff result History label
No fields changed (e.g. typed then reverted before the pause) No entry is created; hasUnsavedChanges and future (the redo stack) are both restored to whatever they were before the burst started — scheduleDebouncedSnapshot() clears future as soon as a burst begins, so without restoring it on this no-op path, a burst that turns out to have changed nothing would still permanently wipe any pending redo history
Only sanilwb_admin_label changed Renamed to {new name}
Exactly one other field changed Edit {item name} {field label} (e.g. "Edit Container 1 Background Color")
More than one field changed {item name} updated (naming just one of several changed fields would be misleading)

Field labels are resolved via getFieldLabel() against the same dialog tab configs (getWidgetDialogTabs(), DIV_DIALOG_TABS) that PageBuilderSettingsSidebar renders fields from. A responsive field's __tablet/__mobile suffix is stripped before lookup and the breakpoint name is appended to the result (e.g. "Horizontal Gap (Tablet)"). Fields not present in the static config fall back to a humanized version of the raw field key. DEFAULT_ITEM_LABELS ({ widget: 'Widget', div: 'Container' }) supplies the generic fallback name when an item has no admin label.

Divs

Action Description
addDiv(parentId, afterId?) Adds a new, empty div as a child of parentId (or at the root when parentId is null). When afterId is given, inserts immediately after that sibling; otherwise appends. Generates a sequential admin label ("Container 1", "Container 2", ...) via resolveUniqueAdminLabel() so it never collides with a manually-renamed div.
duplicateDiv(divId) Deep-clones a div and everything inside it, regenerates every id in the cloned subtree, assigns fresh sequential labels to every div/widget in the clone, and inserts the copy immediately after the original as a sibling.
removeDiv(divId) Removes a div (and everything inside it) from the tree
removeNewDiv(divId) Removes a brand-new, never-edited div the user cancelled — no undo snapshot. Also cancels any pending debounced edit session for this div via cancelDebouncedSnapshot(), so a burst that was mid-flight when the user cancelled can't still commit afterward and let a later Undo bring the div back.
updateDivOptions(divId, options) Replaces a div's options object (background, layout, spacing, admin label, etc.) — debounced (see above)

Widgets

Action Description
addWidget(parentId, type, insertAt?) Creates a new widget of the given type as a child of the div identified by parentId. insertAt is an optional array index — omitted (or past the end) appends to the end; this is how the canvas's per-widget "+" button inserts a widget right after the one the user hovered. Returns the widget object so the caller can open its settings dialog.
removeWidget(widgetId) Removes a widget (creates undo snapshot)
removeNewWidget(widgetId) Removes a brand-new widget the user cancelled — no undo snapshot. Also cancels any pending debounced edit session for this widget — see removeNewDiv() above.
duplicateWidget(widgetId) Inserts a deep copy of the widget immediately after the original, with a fresh sequential admin label
updateWidget(widgetId, values) Replaces a widget's values object after the settings panel saves — debounced (see above)

Move

Action Description
moveNode(nodeId, newParentId, newIndex) Moves any node (div or widget) to become a child of newParentId (or the root, when null) at newIndex. Used for every drag scenario in the Layers Panel — reordering siblings, reparenting into a different div, moving to/from the root. Rejects (no-ops) a move that would create a cycle — dropping a div inside itself or inside one of its own descendants — via wouldCreateCycle(); that would break the grid-column invariant, via isValidGridColumnMove(); or that would nest a Loop/Carousel-enabled div inside another one, via subtreeContainsLoopOrCarousel() + isDivOrAncestorLoopOrCarouselActive() (both in treeTraversal.js) — this last check closes a gap where the Loop/Carousel tab's own settings-UI nesting guard (see Loop) didn't cover a drag-and-drop move, only manually enabling the toggle.

Layer panel expand/collapse

Action Description
collapseLayer(id) Marks a layer bar as collapsed by adding its id to collapsedLayerIds
expandLayer(id) Removes a layer bar from collapsedLayerIds, expanding it
expandAncestorsOf(nodeId) Walks the full sections tree to find the node, then calls expandLayer() on every ancestor div, however many levels deep. Called by CanvasWidget/addWidget() so collapsed parents are automatically opened when a new or clicked item needs to be visible.

Settings panel

Action Description
openSettingsPanel(type, id, isNew?) Opens the settings sidebar for the given item (type is 'div' or 'widget'). isNew defaults to false; pass true for a freshly created div/widget so closing without saving removes it.
closeSettingsPanel() Clears activeSettingsTarget and settingsPanelIsNew.

Serialization / init

Action Description
initFromWindowData() Parses window.PageBuilderData.builderData and seeds sections[]
serializeToJson() Returns the PHP-compatible JSON string for the sanilwb_data hidden input

Tree utilities (not store actions)

File: admin/assets/js/src/page-builder/utils/treeTraversal.js

Plain functions the store actions and UI components call into — not part of the Zustand store itself:

  • findNodeById(sections, id) / getChildrenOf(sections, parentId) — lookups by id.
  • countWidgetsByType(sections, type) / countAllDivs(sections) — used to generate sequential admin labels ("Posts 1", "Container 2", ...).
  • collectAdminLabels(sections, excludeId?) / makeAdminLabelUnique(desiredLabel, existingLabels) / resolveUniqueAdminLabel(sections, excludeId, desiredLabel) — the admin-label uniqueness machinery addDiv/addWidget/duplicateDiv/duplicateWidget all use.
  • findAncestorIds(sections, targetId) / findBreadcrumbPath(sections, targetId) — breadcrumb trail for the settings sidebar (see Settings Sidebar below).
  • isDescendant(ancestorNode, targetId) / wouldCreateCycle(sections, draggedId, newParentId) — used by moveNode() to reject invalid drops.
  • extractNodeById, insertNodeAt — mutate an already-cloned tree in place; moveNode() clones the whole tree once itself, then uses these two to extract-and-reinsert the moved node.
  • insertNodeById(sections, parentId, insertIndex, newNode), updateNodeById, removeNodeById, insertCloneAfter — immutable copy-on-write helpers: each copies only the branches on the path to the change and returns a new tree, without cloning the whole thing. addDiv/addWidget use insertNodeById() for this reason — a full-tree structuredClone() on every add would otherwise duplicate the clone snapshot() already does for the undo stack.

File: admin/assets/js/src/page-builder/stores/pageBuilder/serialization.js

  • toPhpFormat(sections) — serializes the internal tree to the PHP-compatible JSON array shown above.
  • fromPhpFormat(phpData) — deserializes the stored JSON array back into the internal tree shape, recursing on each node's kind. Unrecognised kind values are skipped (logged, not thrown) so old/foreign data doesn't crash the editor. A kind: 'widget' node with no widget_type at all is skipped the same way — it is not guessed at (e.g. defaulted to posts), since rendering a corrupted record as some other real widget type with no indication anything was wrong is worse than dropping it.

UI Components

PageBuilder.jsx

File: admin/assets/js/src/page-builder/PageBuilder.jsx

The root component. Renders:

  • BuilderTopbar — shared topbar shell from shared/components/BuilderTopbar.jsx. Left slot: a hamburger (☰) ActionsDropdown menu (Show Layers / Show History / Exit) + post title (or a name input, in template mode). Center slot: device toggles. Right slot: a Canvas Settings button (opens CanvasSettingsSidebar), a Preview button, and SaveButton. There is no separate topbar Close button — Exit is one of the hamburger menu's items.
  • NoticeBar — renders every notice queued in useNoticeStore, directly below the topbar.
  • Body — LayersPanel and HistoryPanel on the left, PreviewPanel fills the centre, PageBuilderSettingsSidebar sits on the right when a div/widget is being edited, SaveAsTemplateDialog when a "Save as Template" action is active, and CanvasSettingsSidebar (see below) is always mounted (visibility driven by its own store flag). See Open/Close Animations below for how each panel's mount timing actually works now that they slide open/closed.
  • PageBuilderSettingsSidebar — reads activeSettingsTarget from the store and renders the appropriate form fields (div or widget) inside the shared SettingsSidebar shell. Opening any layer bar row calls openSettingsPanel(type, id) in the store, which this component subscribes to.
  • CanvasSettingsSidebar (components/CanvasSettingsSidebar.jsx) — opened from the topbar's Canvas Settings button, in every context. Holds header/footer visibility toggles (headerVisible/footerVisible in the store) plus the Preview Post picker (PreviewPostPicker.jsx) or Preview Term picker (PreviewTermPicker.jsx, for an archive-type Site Layout) whenever the document needs a stand-in subject to preview ambient widgets against (Site Layout entries and template mode). Reuses the same SettingsSidebar shell as PageBuilderSettingsSidebar; the two are mutually exclusive.
  • SaveAsTemplateDialog (components/SaveAsTemplateDialog.jsx) — opened from a container's right-click/ellipsis menu in the Layers Panel. Extracts that container's own subtree and saves it as a new wp_sanilwb_templates row (Name/Type/Category), reusing serializeNode() from the same serialization module the main save flow uses.

PageBuilder only reads hasUnsavedChanges from the store (for the close-button confirm dialog). All save logic lives in SaveButton.

Open/Close Animations

The three docked panels — LayersPanel, HistoryPanel, and the settings sidebar (SettingsSidebar.jsx's shell, shared by PageBuilderSettingsSidebar, CanvasSettingsSidebar, and SaveAsTemplateDialog) — slide open and closed (width transition, 0.2s ease) rather than appearing/disappearing instantly.

The hook: admin/assets/js/src/shared/hooks/useAnimatedMount.js. Given an isOpen boolean, it returns:

Return value Purpose
shouldRender Whether the panel should be in the DOM at all. Stays true for a moment after isOpen goes false, so the close transition has time to play before the panel actually unmounts.
isVisible Whether the panel's "open" modifier class should be applied. Deliberately delayed by one requestAnimationFrame after mount/open so the browser paints the closed state first — without this, mounting already-open and adding the open class in the same paint skips the transition entirely.
handleTransitionEnd Pass to the panel's root element's onTransitionEnd. Ignores transitions bubbling up from child elements (compares event.target to event.currentTarget) and flips shouldRender to false once the panel's own close transition finishes.

Why the panels used to not animate: all three previously unmounted synchronously the instant they closed (if (!open) return null, or the parent gating them with { condition && <Panel/> }) — once a node leaves the DOM on the same render, there is no time left for CSS to transition its exit.

CSS pattern: each panel's SCSS (_pb-layers.scss, _history-panel.scss, _settings-sidebar.scss) sets its base width to 0 with overflow: hidden and transition: width 0.2s ease, then a --visible modifier class sets the panel's real fixed width (270px / 270px / 300px respectively). isVisible from the hook toggles that modifier class.

Mounting note: History and the settings sidebar are lazy-loaded (React.lazy), and were previously only mounted in PageBuilder.jsx while actually open. Since useAnimatedMount needs the panel to stay mounted briefly after closing, PageBuilder.jsx now mounts their Suspense boundary once each has been opened for the first time (hasOpenedHistoryPanel / hasOpenedSettingsPanel — one-way flags that never reset back to false) and leaves it mounted from then on; actual visibility is still driven by the isOpen prop passed down.

SaveButton.jsx

File: admin/assets/js/src/page-builder/components/SaveButton.jsx

Owns the entire save flow:

  • Reads serializeToJson and markSaved from usePageBuilderStore directly — no props needed.
  • Disabled only while a save is in flight or during the brief "Saved" confirmation (saveState !== 'idle') — not tied to whether there are unsaved changes. Clicking Save with nothing changed simply re-saves the current state; this prevents a second AJAX request from firing mid-request, not unnecessary ones.
  • State machine: idlesavingsaved (1.5s) → idle. A synchronous isSavingRef mutex provides a secondary guard against programmatic double-calls.
  • POSTs to window.PageBuilderData.ajaxUrl with action sanilwb_pb_save.

Keyboard shortcuts: - Ctrl+Z (or Cmd+Z) — undo - Ctrl+Y / Ctrl+Shift+Z (or Cmd equivalents) — redo - Shortcuts are ignored when focus is inside an INPUT, TEXTAREA, SELECT, or a contentEditable element. - Attached twice: once in PageBuilder.jsx on the parent window (covers the layers panel, topbar, sidebar), and once in IframeCanvas.jsx on iframe.contentWindow (covers the canvas). See PreviewPanel.jsx / IframeCanvas.jsx below for why the second listener is necessary.

Device state: useDeviceStore (admin/assets/js/src/shared/stores/useDeviceStore.js) is the single source of truth for the active device (desktop | tablet | mobile) and drives the widget/div settings dialog's responsive field cascade. The top bar buttons call setActiveDevice(). PreviewPanel reads the device and passes it to IframeCanvas, which resizes the iframe using its own separate width map (see PreviewPanel.jsx / IframeCanvas.jsx below) — the two are driven by the same activeDevice value but are not the same pixel numbers.


LayersPanel.jsx

File: admin/assets/js/src/page-builder/components/LayersPanel.jsx

The layers panel is the primary editing interface. The canvas is purely visual — all create, delete, reorder, and settings actions happen here.

Visibility: controlled by activePanel state in PageBuilder. Only one of LayersPanel or HistoryPanel is visible at a time. The layers header has a "show history" button and a close button.

Open/close animation: the panel slides open/closed (width transition, 0.2s ease) via the shared useAnimatedMount hook (admin/assets/js/src/shared/hooks/useAnimatedMount.js) — see Open/Close Animations below for how this works across all three docked panels.

Empty state: when sections.length === 0, shows a placeholder — "No containers yet" / "Use the canvas to add your first container."

Layer bar color coding:

Kind Component Color
Div DivLayerBar.jsx (sanilwb-layer-item--div) Violet
Widget WidgetLayerBar.jsx (sanilwb-layer-item--widget) Amber

Every div — root-level or nested any number of levels deep — shares the same violet color; only the leaf widget kind gets its own (amber). There is no per-widget-type accent color on the bar itself (the widget picker's cards do use per-type colors — see WIDGET_PALETTE under Widget System below).

Selection sync: clicking a widget or div's body in the canvas preview calls setSelectedItemId, which highlights the matching bar in the layers panel (a useEffect + scrollIntoView scrolls the bar into view). Clicking a bar in the layers panel, or an item's settings icon in the canvas, calls openSettingsPanel instead, which sets selectedItemId itself as part of opening the sidebar — so only one item is ever highlighted at a time, regardless of whether it was last selected via the canvas, the layers panel, or the settings icon.

Settings panel auto-close: clicking a widget or div's body in the canvas (not its settings icon) closes the settings sidebar if it was open for a different item — otherwise the sidebar would keep showing that other item's fields while the layers panel highlights the newly-clicked item, which reads as if the sidebar belongs to it. If the sidebar was already open for the same item that was clicked, it stays open.

Auto-expand: expandAncestorsOf(nodeId) walks the sections tree and removes every ancestor div's id from collapsedLayerIds, so collapsed parents are opened before a newly-added or clicked item's bar needs to be visible.

Collapse state: DivLayerBar derives its open/closed state from collapsedLayerIds in the store via a per-ID boolean selector (!s.collapsedLayerIds[id]). The toggle button calls collapseLayer(id) or expandLayer(id). Using a derived boolean means only the bar whose state changed re-renders — not every bar in the panel.

Drag and drop: uses @dnd-kit/core with a single DndContext wrapping the entire panel. Both div and widget bars are useSortable nodes, all sharing one SortableContext per parent's children[] so widgets and child divs can be dragged past each other freely. handleDragEnd resolves the drop target and calls moveNode(nodeId, newParentId, newIndex) — the one move action for both kinds (see Store above). A move that would nest a div inside itself or one of its own descendants, break the grid-column invariant, or nest a Loop/Carousel-enabled div inside another one is rejected by moveNode() itself (see its own row in the Store table above) and shows a brief rejection message in the panel instead of applying.

newIndex is read from a snapshot of the sibling array taken before the drop — but moveNode() always removes the dragged node from the tree first, before re-inserting it, which shifts every later sibling's index down by one. handleDragEnd accounts for this: when the drop lands in the dragged node's own current parent and the node's pre-removal position is before the computed target index, it decrements newIndex by one so the node still lands where the user actually dropped it. No adjustment is needed when reparenting into a different div, since removing from one array doesn't shift indices in another.

A DragOverlay renders a lightweight clone (LayerDragClone.jsx) of the dragged item that follows the cursor. The original item is hidden during drag (opacity: 0). Drag activation requires 6px of movement to prevent accidental drags on click. onDragCancel resets the same drag state onDragEnd does (activeId/activeDragData) — @dnd-kit fires onDragCancel instead of onDragEnd when a drag is aborted (Escape key, or the dragged item unmounting mid-drag), so without this the overlay and hidden original could get stuck until another full drag completed.

Actions per layer type (each bar's ellipsis menu, ActionsDropdown — also openable via right-click):

DivLayerBar — clicking the bar opens its settings sidebar directly (no separate settings button); ellipsis menu: Add Widget (opens WidgetPickerDialog, which lists a Container card alongside every widget type — picking Container calls addDiv(), picking a widget type calls addWidget()), Add Container, Add Container Below, Duplicate, Delete.

WidgetLayerBar — clicking the bar opens its settings sidebar; ellipsis menu: Duplicate, Copy Shortcode, Delete.

Clicking any layer row calls openSettingsPanel(type, id) in the store. The expand/collapse toggle and action buttons call stopPropagation so they do not also trigger the settings panel.

Action button tooltips: All icon-only action buttons in the layers panel are wrapped with HoverTooltip (from shared/components/HoverTooltip.jsx) using direction="below". The below direction is required because the layers panel has overflow-y: auto — the scroll container clips anything that would appear above the button. Tooltips appear below the button with an upward-pointing arrow, right-anchored so they stay within the panel's right edge.


HistoryPanel.jsx

File: admin/assets/js/src/page-builder/components/HistoryPanel.jsx

Thin wrapper that connects the shared admin/assets/js/src/shared/components/HistoryPanel.jsx presentational component to usePageBuilderStore's past, future, and jumpToHistory — the timeline rendering logic (flat list, current-entry highlight, jump-index math, empty state) lives once in the shared component rather than being duplicated per consumer.

The shared component displays a flat, stable timeline of all actions. The list never reorders — undo/redo only moves the blue highlight within the fixed list, like a cursor through static text. The user can click any entry to jump directly to that point using jumpToHistory().

The timeline is built as [ ...future, ...[ ...past ].reverse() ] — future entries (undone actions) appear at the top as redoable, past entries follow in reverse so the most recent past action is closest to the cursor.

Styling: the shared component uses its own sanilwb-history-panel* class names, defined once in admin/assets/css/shared/_history-panel.scss and imported by admin-styles.scss (the styles Page Builder actually loads) — not the sanilwb-pb-layers* classes that LayersPanel.jsx uses for its own panel shell.

Mounting and open/close animation: PageBuilder.jsx mounts the lazy-loaded Suspense/HistoryPanel boundary once the panel has been opened for the first time (hasOpenedHistoryPanel, a one-way flag that never resets) and keeps it mounted from then on — actual visibility is driven by an isOpen prop (activePanel === 'history') passed down to the shared component, which uses useAnimatedMount to slide open/closed and only actually unmount its content after the close transition finishes. This is necessary because the panel used to be removed from the DOM the instant activePanel changed away from 'history', which left no time for a CSS transition to play.


PreviewPanel.jsx / IframeCanvas.jsx

Files: admin/assets/js/src/page-builder/components/PreviewPanel.jsx, admin/assets/js/src/page-builder/components/IframeCanvas.jsx

PreviewPanel is a thin wrapper that passes device through to IframeCanvas.

IframeCanvas loads a real WordPress page (/?sanilwb_pb_canvas=1) into an iframe. This gives the canvas full frontend CSS — theme styles, plugin styles, compiled template CSS — without any hardcoded stylesheet list. The canvas URL strips all <script> tags from wp_head() and omits wp_footer() to prevent theme/plugin JavaScript from navigating away or conflicting with the React root.

Dedicated React root: the canvas mounts its own createRoot inside the iframe's document body. This is necessary because react-frame-component uses ReactDOM.createPortal, which keeps event delegation on the parent document — click handlers inside the iframe silently fail. With a dedicated createRoot, React sets up event delegation on the iframe's own body, so all onClick handlers work normally.

Shared store: both the main document's React root and the iframe's React root import the same usePageBuilderStore module from the same JS bundle. Zustand stores are module-level singletons, so both roots read and write the same state instance automatically.

Undo/redo keyboard shortcut inside the iframe: keydown events that originate inside the iframe fire on the iframe's own window, not the parent's — they never bubble to PageBuilder.jsx's listener. IframeCanvas therefore attaches its own Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z listener to iframe.contentWindow inside mountCanvas(), calling usePageBuilderStore.getState().undo() / .redo() directly. It is removed on unmount via the removeKeyDownListener ref. This mirrors the parent listener's input-field guard so typing inside canvas-rendered fields is not intercepted.

Carousel scripts: the canvas page strips <script> tags, so Embla Carousel and the carousel initialiser (public/js/sanilwb-carousel.js) are injected manually after the page loads. They are injected sequentially (Embla → carousel init). After injection, a scriptsReady ref is set to true, a shared emblaReady store flag is set to true, and a sanilwb:content-loaded CustomEvent is dispatched on the iframe document. A store subscription on sections fires this event again whenever the layout changes, debounced 120ms so a burst of keystrokes triggers one rescan, not one per keystroke (fixed 2026-08-20 — the subscription previously also had a subscribe() arity bug that silently dropped the dispatch callback entirely; Zustand's plain create() store subscribe() takes a single (state, prevState) listener, not a (selector, callback) pair).

That event/script combination is still what drives the real, published frontend's own Carousel containers when they appear inside the canvas iframe's loaded page. A div-level Carousel container (sanilwb_carousel_enabled) in the editor canvas is handled differently: admin/assets/js/src/page-builder/components/canvas/CanvasCarousel.jsx owns a real Embla instance directly through React (created once, then updated via embla.reInit() on any settings/slide-count change — never destroyed and recreated), reading window.EmblaCarousel off the iframe's own window via viewportRef.current.ownerDocument.defaultView (its component code runs in the parent bundle, not as a script injected into the iframe) once the emblaReady flag is set. Its wrapper carries data-carousel-owner="react" so sanilwb-carousel.js's own DOM scan skips it, avoiding a second, competing Embla instance on the same element.

This means the editor canvas's Carousel container and the real frontend's Carousel container are two separate implementations, not shared code — see the "Carousel: Editor and Frontend Are Two Separate Implementations" section in the plugin root CLAUDE.md for why (the editor needs to react instantly to live settings edits; the vanilla script only noticed a changed slide count, not a changed setting) and what has to be updated in both places going forward.

Arrow Position (Below / Inside / Outside): a responsive Carousel field (sanilwb_carousel_arrow_position) resolving to a modifier class on the .sanilwb-carousel wrapper — sanilwb-carousel--arrows-inside / --arrows-outside, or no class at all for the default "below". Both implementations resolve and apply it the same way as every other Carousel setting: the editor via resolveCarouselSettings() per the current preview device, the frontend via the arrowPosition key in the data-carousel JSON plus sanilwb-carousel.js's apply_breakpoint_ui() swapping the class on load and on resize. The CSS itself (public/css/sanilwb-styles.scss) absolutely-positions the arrow buttons against .sanilwb-carousel (see its position: relative in public/css/_global.scss) — centered against the whole component's height (slider + dots row), not the slider alone, a deliberate simplification.

Dot count comes from Embla itself, not a rendered/counted number. Both implementations learned this the hard way: PHP/React can only guess how many scroll positions a carousel will have (Slides to Scroll groups slides into fewer snap points than the raw slide count), so both sides ask Embla directly once it's running rather than trusting their own guess. The editor reads embla.scrollSnapList().length reactively (CanvasCarousel.jsx). The frontend's sanilwb-carousel.js adopts PHP's pre-rendered dots when the count already happens to match (the common case, avoiding a flash), otherwise rebuilds them to match embla.scrollSnapList().length — done once on init and again after every resize-triggered reInit(), since crossing a breakpoint can change the real count.

Editor-only edge cases worth knowing: an empty Carousel container (no children yet) skips CanvasCarousel entirely and falls back to the plain "+" button — otherwise that button would become Embla's one and only "slide" as soon as Carousel is toggled on, before any real content exists. A device-hidden child (sanilwb_show_desktop/tablet/mobile) is routed outside the Embla track entirely (a sibling display:none holder), not just hidden in place — Embla treats every direct track child as a real slide regardless of CSS visibility, so a hidden child left inside would still occupy a slide/dot position.

Arrow Color / Dots Color are real Style-tab fields, not hand-built CSS (as of 2026-08-06). They live on the div's Style tab, in a "Carousel" section (not the Content tab, where the rest of the Carousel settings live) — container.styles.json's ownFields declares them with target: ".sanilwb-carousel-prev,.sanilwb-carousel-next" / .sanilwb-carousel-dot, the same cssProperty escape hatch any widget's own custom color field already uses (see Style Field JSON Reference). This is why they moved tabs: every widget's own color fields (Icon's Fill Color, etc.) live on the Style tab, and Carousel's colors now follow that same convention.

Both implementations were previously hand-building this: CanvasCarousel.jsx set style={{ color: ... }}/style={{ backgroundColor: ... }} directly from the raw field values, SANILWB_CSS_Compiler::build_carousel_controls_html() baked the same into its returned HTML string, and public/js/sanilwb-carousel.js (the real frontend's own script) also independently re-applied the color as an inline style on init and on every resize. All three are gone now — CanvasDiv.jsx resolves the color via the same buildElementStyles() call every div/widget style field goes through and passes it down as a prop; SANILWB_Frontend_Renderer::render_div()/render_root_wrapper_open() run the same generic per-target CSS loop render_widgets() already used for every widget's own custom target, just with 'container' as the type; the vanilla script no longer touches color at all, relying purely on the resulting responsive CSS rule (a real @media-scoped rule per breakpoint, matching the div's own Arrow Color (Tablet)/(Mobile) values, exactly the way any other responsive field already works). The one thing that legitimately stays inline in both places: the dots' active/inactive opacity, since that's per-index runtime state, not a stored field value.

Device widths (iframe pixel width — separate from useDeviceStore's own DEVICE_DEFAULT_WIDTHS used by the settings-dialog device toggle):

Device iframe width
desktop 100%
tablet BREAKPOINTS.CANVAS_TABLET (768px)
mobile BREAKPOINTS.CANVAS_MOBILE (375px)

Width transitions are animated (transition: width 0.25s ease). On tablet and mobile the iframe is horizontally centred with margin: auto. There is no Bootstrap grid dependency here — a root div's children lay out via its own flex options, which resize naturally with the iframe width.


Widget System

Widget type registry

File: admin/assets/js/src/page-builder/config/widgets/index.js — assembles WIDGET_TYPES from one subfolder per widget type living in config/widgets/ (heading/heading.js, paragraph/paragraph.js, image/image.js, icon/icon.js, button/button.js, postTitle/postTitle.js, archiveTitle/archiveTitle.js, featuredImage/featuredImage.js, postContent/postContent.js, excerpt/excerpt.js, termName/termName.js, author/author.js, date/date.js, shortcodes/shortcodes.js, carouselNav/carouselNav.js, templateReference/templateReference.js, video/video.js, socialIcons/socialIcons.js, menu/menu.js, spacer/spacer.js, textInput/textInput.js, textarea/textarea.js, selectField/selectField.js, radioGroup/radioGroup.js, checkbox/checkbox.js, submitButton/submitButton.js), each exporting definition (the registry entry below) and its own buildTabs(...) function.

WIDGET_TYPES is a plain object keyed by type string. Each entry (definition) defines:

Property Type Purpose
type string Key (e.g. 'heading')
label string Human-readable name shown in the widget picker and layer bars
icon Lucide component Icon component imported from 'lucide-react' (e.g. RectangleHorizontal), rendered directly in the widget picker — not a string class name
barBg string CSS color for this type's card in the Widget Picker (WIDGET_PALETTE in config/widgets/widgetPalette.js)
barText string CSS text color (paired with barBg)
defaultValues object Initial sanilwb_* values applied when a new widget is created

The div/Container entry shown in the Widget Picker is not part of WIDGET_TYPES — it's a small static entry (CONTAINER_PICKER_ENTRY) defined locally in WidgetPickerDialog.jsx, using the same { type, label, icon, barBg, barText } shape so it renders through the same grid, but with type: 'div' so onSelect() can branch to addDiv() instead of addWidget(). It is deliberately kept out of the real registry since div is a structural node, not a widget (it isn't a valid target for countWidgetsByType(), doesn't have values/defaultValues, etc.).

Picker grouping. After filtering by context and search text, WidgetPickerDialog.jsx sorts the remaining cards into labeled sections via a local WIDGET_GROUPS array — Layout (Container, Off Canvas, Form — the three kind: 'div' picker entries, not real WIDGET_TYPES members), Basic (heading, paragraph, button, image, icon), Form Fields (text_input, textarea, select, radio_group, checkbox, submit_button — see Forms), and Dynamic (post_title, archive_title, term_name, featured_image, post_content, excerpt, author, date, shortcodes, template_reference, carousel_nav, menu). Every widget type must appear in exactly one group's types list; a widget type not yet added to any group falls into a catch-all Other section rather than silently disappearing from the dialog, so adding a new widget type to WIDGET_TYPES without also adding it to WIDGET_GROUPS is safe but leaves it uncategorized until fixed — as of this writing video/social_icons/spacer are in this state, falling into Other.

Dialog tabs are not stored in WIDGET_TYPES — each widget's own file exports a buildTabs(...) function, assembled by getWidgetDialogTabs(type, availableTemplates, categories, imageSizes) (also in config/widgets/index.js) which is called at dialog open time. This keeps template picker options (which come from window.PageBuilderData) out of the module-level constant.

Built-in widget types

Type Label Description
button Button CTA button — Content tab (Label, optional URL, Link to Topic, Clickable). No widget-specific Style tab fields anymore — it draws entirely from the shared scheme (see Button Widget Styling below)
heading Heading Static heading with a chosen tag (h1–h6), editor-entered text, optional link, and the Typography fields (plus Link Hover Color, since it can have a link)
paragraph Paragraph Static block of plain text (no rich text/multi-paragraph support — see note below) with the Typography fields
image Image Media-library image, stored as an attachment ID so wp_get_attachment_image() can output a responsive srcset at the chosen registered size
icon Icon A Lucide icon (picked by name), with stroke width and an optional link URL. Has its own Icon style section (Icon Size, Color, Fill Color, all targeting the rendered <svg> directly) instead of the shared Layout/Size/Typography sections, which have no meaning on a leaf icon
post_title Post Title Dynamic — renders get_the_title() against the ambient WP post context, with a tag select, a link-to-post toggle, and the Typography fields (plus Link Hover Color)
archive_title Archive Title Dynamic — renders get_the_archive_title() (category/tag/date/author/post-type labeling handled by WP core), with an optional prefix toggle and the Typography fields
featured_image Featured Image Dynamic — delegates to the sanilwb_get_featured_image() helper, with a size select and link toggle
post_content Post Content Dynamic — renders apply_filters('the_content', get_the_content()) (the same filter chain WP core's the_content() runs), no manual content field, with its own per-selector Style tab (Body Text, H1–H6, Images) — not the shared Typography fields
excerpt Excerpt Dynamic — renders get_the_excerpt() against the ambient post context, with an optional word-count override that scopes the excerpt_length filter to this render only, and the Typography fields
term_name Term Name Dynamic, ['page']-only — only shows real content inside a Loop div whose Query Type is Terms (WordPress has no ambient "current term" the way it has a current post), with a tag select, link toggle, and the Typography fields
author Author Dynamic — renders the ambient post's author name, with an optional avatar and a toggle to link the name to the author archive, plus the Typography fields (plus Link Hover Color)
date Date Dynamic — Format select (Time Ago / Readable / Custom, the last revealing a PHP date()-style format field), a Date Source select (the ambient post's published date, or the current moment regardless of any post context), a Language select (English or Nepali/Bikram Sambat — a per-widget choice, independent of the Theme Options site-wide date language), and an optional clock icon, plus the Typography fields
carousel_nav Carousel Nav Standalone prev/next control wired to a carousel placed elsewhere in the same layout — not context-bound to a post, so it has its own Button Size field alongside the shared Appearance/Typography fields
shortcodes Shortcodes Raw shortcode/embed code passthrough
template_reference Dynamic Template ['page']-only — a live pointer to a saved template, rendered fresh on every request, with per-placement content overrides; see Dynamic Template Widget above
video Video Local (media library) or YouTube source, with an optional cover image before play
social_icons Social Icons Repeater of platform + link rows, each rendered from a matched brand icon (shared/config/brandIcons.js)
menu Menu Renders a real WordPress nav menu (picked by ID, not typed) as a <ul>/<li>/<a> tree at any nesting depth — PHP/AJAX-rendered, since only PHP can resolve real menu data
spacer Spacer Pure vertical gap, one Height field
text_input Text Input Form field — <input type="text"> or <input type="email"> (toggle-switched); see Forms
textarea Textarea Form field — <textarea>; see Forms
select Select Form field — <select> with a repeater-driven option list; see Forms
radio_group Radio Group Form field — a repeater-driven set of radio inputs; see Forms
checkbox Checkbox Form field — single toggle or repeater-driven group, switched by a Mode field; see Forms
submit_button Submit Button Form field — <button type="submit">; see Forms

Forms — the six text_input/textarea/select/radio_group/checkbox/submit_button types above are usable standalone (as in this table) or inside a Form — a kind: 'div' node (not a real WIDGET_TYPES member, same as Container/Off-Canvas) that renders as a real <form> and pipes submissions to this plugin's own database + admin screen. See the dedicated Forms feature page for the Form div itself, submission storage, the AJAX endpoint, and the admin Submissions screen.

"Dynamic" widgets read whatever post WordPress's current loop/query has in scope (get_the_title(), get_the_content(), etc.) rather than storing their own content — they have no widget-owned WP_Query, consistent with the plugin's rule that Page Builder widgets do presentation, not data-fetching. On a regular Page there is exactly one implicit post in scope. Inside a real WP loop (have_posts()/the_post()) they resolve per-iteration — this is the mechanism Site Layout relies on for its archive/search templates.

Note on Paragraph: the shortcode attribute pipeline runs every attribute through sanitize_text_field() in SANILWB_Shortcode_Handler::process_shortcodes() (includes/class-sanilwb-shortcode-handler.php) before any widget class sees it, which strips tags and collapses line breaks to single spaces. Multi-paragraph text is not possible via this widget — plain single-paragraph text only.

getWidgetDialogTabs()

Builds the full tab array for the given widget type. After the widget-specific tabs are built:

  1. Finds the style tab and appends SHARED_STYLE_FIELDS into it — the one shared Style tab scheme (see SHARED_STYLE_FIELDS below), used identically by every widget and by div (DIV_DIALOG_TABS).
  2. Appends the Advanced tab (buildSharedAdvancedTab(), see below) at the very end, passing this widget's own attributeTargets (from its .styles.json) so its Attributes section is built correctly.

This means every widget dialog ends with a consistent Style and Advanced tab structure. See WIDGET-ARCHITECTURE.md (admin/assets/js/src/page-builder/WIDGET-ARCHITECTURE.md in the plugin source) for the full design rationale — read it before adding a widget-specific Style field, since it likely duplicates something the shared scheme already provides.


Settings Sidebar

PageBuilderSettingsSidebar.jsx

File: admin/assets/js/src/page-builder/components/PageBuilderSettingsSidebar.jsx

The settings panel for divs and widgets. Sits to the right of the canvas in the body flex row and pushes it left when open (300 px fixed width).

  • Reads activeSettingsTarget from usePageBuilderStore. When it is non-null, looks up the target item with findNodeById (utils/treeTraversal.js) — the same lookup works for both a div and a widget, since they share one tree.
  • Computes a breadcrumb trail (findBreadcrumbPath, now in utils/treeTraversal.js) showing the path of ancestor divs to the current item (e.g. Page › Container 1 › Container 3).
  • The DeviceProvider is keyed on the target id — switching items unmounts and remounts it, which re-seeds the dialog buffer with the new item's saved values and resets the isDirty flag.
  • handleClose — if settingsPanelIsNew is true, calls removeNewWidget(id) or removeNewDiv(id) (based on activeSettingsTarget.type) before closing so a cancelled new item is not left in the tree.
  • handleSave — calls updateWidget() or updateDivOptions() based on type, then closes the panel.

The widget case uses a nested WidgetSidebarContent component that subscribes to the live buffer's sanilwb_style field to re-derive supportsValues whenever the user picks a different template — without re-rendering the whole sidebar.

SettingsSidebar.jsx (shared shell)

File: admin/assets/js/src/shared/components/SettingsSidebar.jsx

Shared shell used by both builders. Props:

Prop Type Purpose
isOpen boolean Whether the panel should currently be open. The caller may keep this component mounted even while closed — see below.
title string Panel header title
breadcrumbs string[] Optional ancestor path shown below the title (Page Builder only)
onClose function Called by the X button
children ReactNode Form content (typically DeviceProvider + DialogBody)

Width is fixed at 300 px via CSS. The header renders the title + X button row first, breadcrumbs below.

Open/close animation: uses the shared useAnimatedMount hook (see Open/Close Animations below) to slide open/closed instead of appearing/disappearing instantly. Because PageBuilderSettingsSidebar is lazy-loaded, PageBuilder.jsx mounts its Suspense boundary once hasActiveSettingsTarget first becomes true (hasOpenedSettingsPanel, a one-way flag) and keeps it mounted from then on, rather than unmounting it every time the target clears — the same pattern the History panel uses. CanvasSettingsSidebar and SaveAsTemplateDialog reuse this same shell and inherit the same open animation automatically (CanvasSettingsSidebar is always mounted so its close animation plays too; SaveAsTemplateDialog's parent still unmounts it immediately on close, so only its open animation plays).

findBreadcrumbPath()

File: admin/assets/js/src/page-builder/utils/treeTraversal.js (not a standalone file anymore — lives alongside the other tree utilities)

Returns an array of ancestor label strings for a given target node id, always starting with 'Page':

Target Example result
root-level widget or div ['Page']
widget or div nested inside containers ['Page', 'Container 1', 'Container 3']

Ancestor labels come from options.sanilwb_admin_label; fall back to 'Container' if empty.


Shared Dialog Components

HoverTooltip

File: admin/assets/js/src/shared/components/HoverTooltip.jsx

Wraps any trigger element in a positioned <span> and shows a dark tooltip bubble on mouse enter. All styles are inline — no external CSS needed, so it works inside the canvas iframe or any admin page.

Props:

Prop Type Default Purpose
text string Label shown in the tooltip bubble
children ReactNode The trigger element (button, icon, etc.) to wrap
direction 'above' or 'below' 'above' Which side of the trigger the bubble appears on. Use 'below' when an overflow: hidden or overflow-y: auto container would clip an upward bubble (e.g. the layers panel sidebar).

The 'below' direction right-anchors the bubble and points its arrow upward toward the trigger. This matches the layers panel requirement where the scroll container clips anything above the buttons.

Shared Dialog component

File: admin/assets/js/src/shared/components/Dialog.jsx

All settings dialogs (widget settings, div/container settings) use this single shared Dialog component. It renders an overlay + modal shell and wraps its body in a DeviceProvider. This gives all child form fields access to the device-aware value cascade (desktop → tablet → mobile fallback).

Props:

Prop Type Purpose
isOpen boolean Whether the dialog is visible
title string Dialog header text
tabs { id, label, fields[], requiresField? }[] Tab definitions — drives the tab bar and field rendering
savedValues object Current values from the store — seeds the dialog buffer
extraValues object Synthetic read-only values injected into showIf evaluation only (not saved)
itemId string\|null Optional — when provided, tracks the active item in useDialogBufferStore for canvas live preview
onSave function Called with serialized values when the user saves
onClose function Called when the user closes without saving
dialogStyle object Optional inline styles on the dialog shell
overlayStyle object Optional inline styles on the overlay

A requiresField on a tab definition disables that tab until the named field has a truthy value in the buffer. (Not currently used by any widget.)

DialogBody (named export)

Dialog.jsx exports a named DialogBody function in addition to the default Dialog. DialogBody renders the tab bar, form fields, and Save / Cancel footer without any outer chrome (no overlay, no title bar). It must be rendered inside a DeviceProvider.

PageBuilderSettingsSidebar uses DialogBody directly — the sidebar shell (SettingsSidebar) provides the chrome, and DeviceProvider is handled by the sidebar component itself.

FormRow's labelActions slot and FieldResetButton

Files: admin/assets/js/src/shared/components/FormRow.jsx · admin/assets/js/src/shared/components/FieldResetButton.jsx

FormRow — the shared stacked label/control shell almost every field type in admin/assets/js/src/shared/components/fields/ renders through — accepts a labelActions prop: any node rendered right-aligned in the label row, next to the label text, above the control itself. It was originally added for InputGroupField's chain-link toggle (Padding/Margin/Border-radius linking) but is a generic slot — any field can pass one or more icons/buttons into it.

FieldResetButton is a small shared icon-only button (the Undo2 icon from lucide-react) that every FormRow-based field now passes into labelActions. It takes isDirty (boolean) and onReset (function) and renders nothing at all when isDirty is false — a field that's already empty has nothing to reset, so the icon only appears once the field actually holds a value. Clicking it clears the field back to '' (empty), not to some declared "default" — most field configs don't declare one, and an explicit blank matches how the device cascade already treats an unset value.

Responsive scope. Reset only clears the currently active device's value — on tablet it clears the tablet override and falls back to desktop, exactly like the normal read/write cascade in useDeviceValues.js. It does not touch sibling breakpoints.

Per-field isDirty/onReset wiring (all in admin/assets/js/src/shared/components/fields/):

Field type Behavior
Text, Number, Textarea, Select, MultiSelect, Radio, FontFamily, FontWeight, FontStyle, ImageUrl, ColumnLayout Straightforward — isDirty is value !== '', onReset writes '' for that field's name.
InputGroupField (Padding, Margin, Border-radius, …) One label row covers 2–4 sub-inputs, so reset clears every sub-input in the group at once. isDirty is true if any sub-input has a value. The reset icon renders alongside the existing chain-link toggle in the same labelActions slot — both fit because .sanilwb-field__label-actions already lays out multiple children with a gap.
TypographyField Same one-row/multiple-sub-fields shape as InputGroupField — reset clears family, weight, and style together.
ColorField Reuses its own pre-existing handleReset() (already used by the in-popover "Clear Color" and "Unlink global color" buttons) rather than duplicating clear logic — the label-row icon is just another caller of the same function.
MediaField Same pattern — reuses the existing handleRemove() used by the preview's "Remove" button.
GradientStopsField Resets both stop positions to '', which falls back to the implicit 0%/100% spread (see the field's own stop1Raw !== '' ? Number(stop1Raw) : 0 fallback) rather than deleting the gradient itself.
WysiwygField TinyMCE owns the DOM once mounted (see the field's own doc comment on why it's uncontrolled), so reset can't just call setValue('') — the editor wouldn't visually update. The mount/lifecycle/reset logic itself now lives in the shared useWpEditor() hook (admin/assets/js/src/shared/hooks/useWpEditor.js), not the field component — WysiwygField just calls it and passes resetEditor into labelActions. resetEditor() checks window.tinymce.get(editorId).isHidden() to tell which tab is active: on the Visual tab it calls tinyMceEditor.setContent(''), which fires the 'setcontent' listener and reports '' back via onChange automatically; on the Text tab it clears the underlying <textarea> directly and calls onChange('') itself, since Quicktext edits bypass TinyMCE's event bus entirely. TemplateContentOverridesField.jsx's Paragraph override rows use the same hook for a real WP editor outside the standard field/buffer pattern — see Per-Instance Content Overrides above.

Not wired up: ToggleField doesn't render through FormRow at all (it's a single full-width row with the label inline next to the switch), so it has no label-row reset icon.


Shared Dialog Fields

File: admin/assets/js/src/shared/config/sharedDialogFields.js

These field definitions are shared across both of Page Builder's contexts (page, template).

SHARED_STYLE_FIELDS

The one shared Style tab section scheme, injected into every widget's and div's Style tab (getWidgetDialogTabs() / DIV_DIALOG_TABS). Full design rationale lives in WIDGET-ARCHITECTURE.md (admin/assets/js/src/page-builder/WIDGET-ARCHITECTURE.md in the plugin source) — read it before adding a widget-specific Style field, since it's very likely already covered here. Sections, in order:

  • Layout — the field itself (sanilwb_layout_type) is labeled Display in the dialog (renamed from "Layout" — the section header already says that, so the field label was redundant), offering Default/Flex/Block/Inline Block/Inline, then Direction/Align Items/Justify Content/Flex Wrap/Align Content/Gap (flex-only, shown via showIf when Layout Type is Flex). Available on every widget, not just div — Layout Type alone is meaningful even on a leaf widget with no children (e.g. setting a widget to Inline Block); the flex sub-fields are simply harmless no-ops on a widget with nothing to arrange. For a div/Container, this is what controls how its children lay out (see the tutorial's Configure the Container's Layout step).
  • Size — Width, Height, Min Width, Min Height, Max Width, Max Height, Flex Grow, Aspect Ratio. A flex item with no explicit flex-basis falls back to its width, so no separate flex-basis field is needed.
  • Typography — Text Color (+ Hover Color, same Normal/Hover toggle every widget gets — functionally meaningful only on widgets that can render a link), Font Size, Font (family), Font Weight, Font Style, Line Height, Text Align. Div/Container's Style tab omits this whole section — see DIV_DIALOG_TABS's withoutTypographySection() in structuralDialogTabs.js — since a div never renders text of its own. See the notes below this list — most of the historical behavior here (unit handling, hover fallback, font-sync-on-save) is unchanged, only where the field lives changed.
  • Appearance — Background Color (+ Background Hover Color), Box Shadow, Gradient (Angle, Start Color, End Color, Color Stops), Border Radius (4 corners), Border Width (4 sides), Border Style, Border Color. All responsive except Gradient.
  • Spacing — Margin (top/bottom), Padding (all 4 sides).
  • Position — Position (defaults to relative when unset), Offset (top/right/bottom/left, shown via showIf when Position is Absolute/Fixed/Sticky), Overflow.

Field names (sanilwb_margin_top, sanilwb_padding_top, sanilwb_border_radius_top_left, sanilwb_border_width_top, sanilwb_border_style, sanilwb_border_color, sanilwb_position, sanilwb_overflow, etc.) must not be renamed — they are read by both the PHP CSS compiler (class-sanilwb-css-compiler.php) and the JS canvas style builder (buildAppearanceStyle.js).

Flex Grow and Position (with its Offset fields) each need to land on the true DOM flex/grid-item element, which is not always the same element the rest of Size/Position paints onto. Every other field in this scheme (Background, Border, Spacing, the rest of Size, and Overflow specifically within Position) still looks correct wherever it's applied, since it only paints a box — but Flex Grow only has any visible effect on an element that is a direct child of its parent's display:flex/display:grid container, and Position (not Overflow) only correctly detaches an element from that same parent's flex/grid flow (or gives a Position-sensitive child the right containing-block reference) when set on that exact element.

This bites in two different shapes, both real, both once-shipped bugs, both now fixed:

  • A div/widget's own rendered DOM split into more than one nested element for other reasons (keeping the hover toolbar outside a filter: blur's subtree, keeping a widget's drag/+-button UI outside its own styled content) — NonRootWrapper.jsx (a non-root div's own outer tag vs. its .sanilwb-canvas-filter-anchor) and splitRootStyle.js (a root div's outer shell vs. its inner .sanilwb-container, the element its children actually render inside) both explicitly carve Flex Grow/Position/offsets out to whichever element is the true flex/grid item. The real frontend's single-element div markup can't have this specific problem, but had an equivalent one for a root div: .sanilwb-container itself never carried its own Position value either, until SANILWB_Frontend_Renderer::collect_position_only_css() was added alongside collect_layout_only_css() to fix it — see render_root_wrapper_open()/render_root_form_wrapper_open().
  • A widget type whose own Size/Position sections redirect to a real inner target (e.g. .sanilwb-field-wrapper/.sanilwb-text-input for Text Input/Textarea/Select/Radio Group/Checkbox, .sanilwb-button for Button, .sanilwb-icon for Icon — any widget where SANILWB_Style_Field_Targets::widget_has_root_target() is false) — Flex Grow/Position/Offset used to silently ride along with the rest of Size/Position onto that redirected target, which is never the widget's own row wrapper (.sanilwb-widget-row/.sanilwb-widget-{uid} on the frontend, .sanilwb-canvas-item-wrapper in the editor canvas) and so had no visible effect at all. Fixed with a matching pair on each side: PHP's collect_widget_flex_grow_css()/collect_widget_position_css() (class-sanilwb-frontend-renderer.php) scope both to the row wrapper unconditionally, while build_widget_target_rule_set() (class-sanilwb-css-compiler.php) now permanently excludes both from every redirected target (Overflow is the one Position-section field still routed there normally, via build_overflow_declaration()). JS's computeWidgetItemWrapperStyle() (page-builder/utils/widgetItemWrapperStyle.js) does the same thing independently for the editor canvas, applied to .sanilwb-canvas-item-wrapper in CanvasDiv.jsx.

Both shapes share one rule going forward: a future new wrapper split, or a future widget type that redirects Size/Position away from root, must repeat this same carve-out, or Flex Grow/Position will silently stop working there too, even though the CSS declaration itself is correct.

Background Color and Border Color accept a theme "Global Color" swatch (var(--color-primary), var(--color-custom-{id})) in addition to a plain hex value, including an 8-digit #rrggbbaa value with a transparency channel. Both are validated with SANILWB_CSS_Compiler::sanitize_color_or_var() in PHP (includes/class-sanilwb-css-compiler.php) and its JS mirror sanitizeColorOrVar() in admin/assets/js/src/shared/utils/colorValue.js (used by buildAppearanceStyle.js) — not WordPress core's own sanitize_hex_color(), which only accepts 3/6-digit hex and would silently strip both a swatch reference and an alpha channel. Any new color field must sanitize the same way, or the canvas preview and the real frontend can disagree about whether the color renders at all.

Box Shadow is a freeform control, not a preset dropdown. It's six fields sharing the sanilwb_box_shadow prefix: an input-group (Horizontal _x, Vertical _y, Blur _blur, Spread _spread — locked to px, no unit switcher, since CSS box-shadow has no % form and one invalid unit anywhere in the value silently drops the whole shadow), a color field (_color), and a toggle (_inset). buildBoxShadowValue() (JS, shared/utils/buildAppearanceStyle.js) and build_box_shadow_value() (PHP, class-sanilwb-css-compiler.php, private) are the one shared place that composes these six fields into a CSS value string — missing offset/blur/spread default to 0px, and an unset color is left out of the string entirely rather than invented, since CSS box-shadow already falls back to currentColor. The same six-field shape, under different prefixes, also drives Image/Featured Image's inner frame (buildImageFrameStyle()) and Post Content's inline images (sanilwb_content_img_box_shadow_*, its own copy of the composer in buildCanvasLiveCss.js since that widget builds CSS text rather than a React style object). There is no more preset system (small/medium/large, _shadows.scss, SHADOW_PRESETS) — it was removed outright rather than kept for compatibility, per this plugin's no-backward-compatibility rule, so any layout saved with the old preset value silently has no shadow now.

buildSharedAdvancedTab()

The Advanced tab appended to every widget and div dialog (buildSharedAdvancedTab( attributesField = null ) — a function, not a static constant, so it can conditionally append an Attributes section without sharedDialogFields.js itself importing anything store-connected; see Custom Attributes for why). Contains:

  • Visibility section — Show on Desktop, Show on Tablet, Show on Mobile (toggles, not responsive)
  • Developer section — Admin Label (sanilwb_admin_label), CSS Class (sanilwb_css_class)
  • Attributes section — a repeater of raw HTML attribute rows (Name/Value/Target), only shown when the caller passes a non-null attributesField (built by buildAttributesField() from that type's own attributeTargets — see Custom Attributes)

The sanilwb_admin_label value is what appears as the layer name in the Layers panel, and (for a Form field widget) is also what a submitted value is keyed by in storage.

Typography Fields

File: admin/assets/js/src/shared/config/sharedDialogFields.js — the Typography section of SHARED_STYLE_FIELDS.

Now part of the one shared Style tab scheme (see SHARED_STYLE_FIELDS above) injected into every widget (and div, minus this section), not a field set individual widget files opt into. Field names are flat sanilwb_*, since each widget owns its own isolated field namespace.

Post Content does not use this field set for its main content. It has no single element to style — see Post Content Widget Styling below for its own, structurally different per-selector system. (It does still get the shared scheme's Typography section for its own outer wrapper, like every widget does — that's separate from its per-tag Body/H1–H6 fields.)

Line Height accepts decimal steps (step: 0.1). NumberField.jsx didn't read a step prop at all until this was added, so every number field — including Line Height — defaulted to the native <input type="number"> whole-number step. Any other number field that needs finer-grained values can opt in the same way by adding step to its field config.

All seven fields are responsive (desktop/tablet/mobile). PHP resolves them in build_typography_declarations() and assembles them into a <style> block scoped to the widget's own [data-uid="{widget_uid}"] selector via build_typography_responsive_style(), both in includes/class-sanilwb-css-compiler.php (SANILWB_CSS_Compiler) — the same shape collect_element_css() (includes/class-sanilwb-frontend-renderer.php) already uses for spacing/border fields, just for a different field set. This <style> block is prepended to the widget's own shortcode return value (like SANILWB_Heading_Shortcode::render()'s $bp_style), so it works identically on the real frontend, a lone hand-typed [sanilwb type="heading"] shortcode, and the AJAX canvas-preview path.

Desktop values live in the same stylesheet block as tablet/mobile, not as an inline style="" attribute. This is deliberate: an inline style on a tag always beats an external stylesheet rule targeting that same tag, no matter how specific the rule or which @media query it's wrapped in (unless that rule uses !important) — so if desktop stayed inline while tablet/mobile lived in a separate stylesheet block, the breakpoint overrides would be computed correctly but silently never actually apply. build_typography_style_attr() (inline, desktop-only) still exists, but only as a fallback for the rare case where a shortcode instance has no shortcode_uid (e.g. a hand-typed [sanilwb ...] shortcode outside the widget loop) — with no unique ID, there's no [data-uid] selector to scope a stylesheet rule to.

Link Hover Color (sanilwb_hover_color) is now shown for every widget, like the rest of the shared scheme — the Normal/Hover section toggle (hoverState: 'normal' / hoverState: 'hover' on the field defs, rendered by the shared FormFields.jsx). It's only functionally meaningful on a widget that can render a link (Heading, Post Title, Image today — see the Link/Hover Tag Rule below); on widgets with no link concept it's simply an inert field, hidden per-widget during manual review as needed rather than omitted from the shared config. build_typography_responsive_style() emits it as a rule at all three breakpoints, resolved from hover_color/hover_color__tablet/hover_color__mobile.

The Link/Hover Tag Rule — always render the link tag, toggle only its href. Heading, Post Title, and Image always wrap their content in <a>, whether or not a URL/link is actually set — only the href attribute is conditional. An <a> with no href isn't a link at all per the HTML spec (doesn't match :link/:any-link, isn't keyboard-focusable, isn't announced as a link by screen readers — no SEO/accessibility downside), so this is safe, and it means hover/resting color rules never have to special-case "no tag exists to target." Full rationale in WIDGET-ARCHITECTURE.md (admin/assets/js/src/page-builder/WIDGET-ARCHITECTURE.md in the plugin source).

  • With a real href: resting color lands on the parent tag via its inline style, but a linked <a> inside it only inherits that color, and the browser's own default link color beats a merely-inherited value — so build_typography_responsive_style() also emits [data-uid="..."] a{color:...!important}, and hover targets [data-uid="..."] a:hover{...!important}.
  • With no href: there's no competing browser default to beat, so resting color already reaches the tag via its own inline style with no extra rule needed, and hover targets [data-uid="..."]:hover{color:...} directly — no !important, no descendant selector.

A resting link color with no Hover Color still gets a hover rule — an automatic fallback, not silence (has-href case only). Because the resting-color rule above uses !important to reliably beat the theme's own default link color, it would also permanently block the theme's default a:hover rule on any breakpoint where a resting color was set but Hover Color was left empty — hovering the link would visibly do nothing. build_typography_responsive_style() (PHP) and buildTypographyHoverStyle() (renderWidgetPreview.js, JS) both check for this per breakpoint and, when it applies, emit [data-uid="..."] a:hover{color:var(--color-link-hover) !important;} instead — falling back to the theme's own hover color variable (see Theme Options) rather than leaving hover dead. No fallback is emitted when a breakpoint has no resting color override at all; in that case the theme's own a/a:hover pair already applies untouched. This fallback doesn't apply (and isn't needed) in the no-href case, since there's no !important blocking anything there.

Both the resting and hover link color rules accept a theme "Global Color" swatch (var(--color-text), etc.), same as Background/Border Color above — sanitized with SANILWB_CSS_Compiler::sanitize_color_or_var() (PHP) and sanitizeColorOrVar() (JS), not sanitize_hex_color().

Font Size carries a selectable CSS unit (units: SANILWB_LENGTH_UNITS on the field definition, rendered via the shared UnitPicker — see admin/assets/js/src/shared/utils/unitValue.js). The stored value is a combined number+unit string (e.g. "16px", "1.5em"), not a bare number — NumberField.jsx always calls formatUnitValue() on change, even when the unit is left at the default px. Any code that reads sanilwb_font_size must split it back apart with parseUnitValue() (or the field's own bare-number value will still parse fine, since parseUnitValue() falls back to px for legacy values with no unit letters) — a plain isNaN()/parseInt() check on the raw value will incorrectly treat every unit-suffixed value as non-numeric and silently drop the font-size declaration. renderWidgetPreview.js's buildTypographyStyleAttr() (the canvas preview's JS-rendered path) makes this call correctly, resolving each field's device-specific value via getResponsiveValue() (the same cascade helper buildAppearanceStyle() uses for spacing/border), and emits its own [data-uid]-scoped <style> tags for the link resting-color and hover-color rules so the editor canvas matches the frontend, including real interactive :hover on the actual rendered DOM inside the canvas iframe.

Font Family requires a save to actually load the font. Selecting a Google Font in the dropdown only emits font-family:"X",sans-serif; in the widget's typography styling — it does not by itself make the browser render that font. On save — a Page (SANILWB_Admin_PageBuilder::save_metabox() / ajax_pb_save()) or a Template (SANILWB_Ajax::save_template()) alike — the saved schema is scanned for any sanilwb_font_family values via the shared SANILWB_Font_Sync::sync_fonts_from_schema('post_{id}' | 'template_{id}', $schema), which registers them and fires SANILWB_Font_Downloader::process_fonts() to download and enqueue the font site-wide. (This shared method replaced the deleted SANILWB_Template_Compiler::extract_and_sync_fonts(), which used to do this only for templates, and a since-fixed gap where the Page path had its own copy of the scan logic but the Template save path had no equivalent call at all.)

Post Content Widget Styling

Files: admin/assets/js/src/page-builder/config/widgets/postContent/postContent.js (buildTabs()) · admin/assets/js/src/page-builder/config/widgets/sharedFields.js (buildTypographyFieldsWithPrefix(), buildSpacingFieldsWithPrefix()) · admin/assets/js/src/page-builder/utils/patchWidgetLive.js (buildPostContentStyleCss()) · includes/class-sanilwb-css-compiler.php (build_post_content_style())

Post Content renders whatever the current post's own body contains — arbitrary paragraphs, headings, images, embeds, shortcodes — via apply_filters('the_content', get_the_content()). Unlike every other Typography widget (one tag, fully controlled by the widget's own fields), Post Content has no single element to style: a shared [data-uid]{...} rule only reaches the wrapper <div class="sanilwb-post-content"> itself, and CSS inheritance can't carry a font-size into headings (browsers' own h1-h6 UA-stylesheet rules always beat an inherited value) or into images (no text properties to inherit at all).

Style tab layout. buildPostContentTabs() returns a single tab (id: 'style', so getWidgetDialogTabs() still merges the shared wrapper-level Spacing/Borders/Other/Position sections in at the bottom) containing, in order: Body Text, H1 through H6, Image. Each text section is generated by buildTypographyFieldsWithPrefix(prefix, label) (Text Color, Font Size, Font, Font Weight, Font Style, Line Height, Text Align) plus buildSpacingFieldsWithPrefix(prefix) (Margin Top/Bottom, Padding all 4 sides) — every section carries its own typography and its own margin/padding, scoped to just that section's tag (p, h1...h6, img), independent of the wrapper-level Spacing section that still targets the widget's outer container as a whole. The Image section swaps typography for Border Radius + Box Shadow (Horizontal/Vertical/Blur/Spread, Shadow Color, Inset Shadow toggle — see the Box Shadow note below) plus the same Margin/Padding fields.

Field naming: sanilwb_{prefix}_{field} — e.g. sanilwb_body_color, sanilwb_h3_font_size, sanilwb_content_img_border_radius. The Images prefix is content_img (not img), to avoid colliding with the Image/Featured Image widgets' own unrelated sanilwb_img_* frame-sizing fields.

PHP generation — build_post_content_style(). POST_CONTENT_STYLE_GROUPS maps each prefix to its selector (body_ => p, h1_ => h1, ..., content_img_ => img). For each group the method builds desktop/tablet/mobile declarations — typography via build_typography_declarations($args, $suffix, $field_prefix, $important) (now accepting an optional field-prefix so it can read body_color instead of just color, plus an $important flag — see below) for text groups, build_post_content_image_declarations() for the Images group, and build_post_content_spacing_declarations() for every group's Margin/Padding — and emits one [data-uid="{uid}"] {tag}{...} rule per group, wrapped in the same tablet/mobile @media pattern build_typography_responsive_style() uses elsewhere. join_css_declarations() safely concatenates the typography/image and spacing fragments with ; — plain string concatenation of two already-;-joined fragments would silently drop the separator between them.

Every declaration is !important. Real post content saved via the classic/Gutenberg editor commonly carries its own inline style="" on individual tags — e.g. clicking the toolbar's Justify button bakes style="text-align:justify" directly onto that <p>. An inline style always beats an external stylesheet rule regardless of selector specificity unless that rule itself uses !important, so without it a Body Text/Heading/Image style change would silently have no visible effect on any element the post's own author already inline-styled. This is why Post Content's declarations are unconditionally !important while the shared TYPOGRAPHY_STYLE_FIELDS widgets (which style their own fully-controlled element, never someone else's authored markup) are not.

JS live-patch mirror — buildPostContentStyleCss() in patchWidgetLive.js reproduces the same field-for-field logic (including the !important flags, hardcoded since these builder functions are only ever used for Post Content) for the editor's live canvas preview. It's dispatched from CanvasWidgets.jsx's 'style' classification branch via buildStyleCssForWidget(widget.type, widget.uid, widget.values) — a small dispatcher that routes post_content to buildPostContentStyleCss() and every other type to the existing buildPostsStyleCss().

Why structuralFields: [] (empty, not omitted). Every field on this widget is style-only — none of them change which content is displayed, since that always mirrors the current post's own body. classifyValuesChange() treats a widget type with no structuralFields key at all as 'unsupported', which falls through unconditionally to a full AJAX re-render — including on a pure device-toggle with no actual value change (this was the original bug: switching the desktop/tablet/mobile toggle alone re-fetched the widget over AJAX every time). Giving Post Content an empty structuralFields array instead makes classifyValuesChange() correctly return 'none' when nothing changed (skip entirely) and 'style' for any real field edit (live CSS patch, no AJAX) on every subsequent render. The one AJAX round trip that's unavoidable — fetching the post's own filtered content — is still forced via CanvasWidgets.jsx's separate postIdChanged check (first mount, or the Site Layout Preview Post changing), independent of this classification.

Button Widget Styling

Files: admin/assets/js/src/page-builder/config/widgets/button/button.js (Content tab fields, defaultValues) · admin/assets/js/src/page-builder/config/widgets/button/button.styles.json (Style tab — every shared section redirected to .sanilwb-button) · includes/shortcodes/class-sanilwb-button-shortcode.php (SANILWB_Button_Shortcode::render()) · includes/class-sanilwb-frontend-renderer.php (render_widgets()'s generic per-target CSS pipeline) · admin/assets/js/src/page-builder/utils/renderWidgetPreview.js (buildButtonStyleAttr()).

Button has no Style tab fields of its own — button.styles.json draws entirely from the shared scheme (useSharedSections), redirecting every section it uses to .sanilwb-button and dropping the Layout section's Direction/Flex Wrap/Align Content/Gap sub-fields via omitFields (a single-tag widget has nothing to arrange).

Content tab: Label (text), Link to Topic (toggle), Clickable (toggle), URL. Label and URL are hidden whenever Link to Topic is on — when on, the button shows the current post's topic/category term name instead of Label. If the post has no resolvable topic term, the button is omitted entirely. Clickable (default on, only shown while Link to Topic is on) controls whether the term name is wrapped in a real <a> linking to the term's archive, or shown as plain, unlinked <span> text — e.g. for a badge-style label that shouldn't be clickable. This resolution happens via the ambient sanilwb_get_post_preview AJAX fetch in the editor (renderWidgetPreview.js's renderButtonWidget(), which becomes async only for this mode) and via get_field('topic', ...) + the sanilwb-topic taxonomy term on the frontend (SANILWB_Button_Shortcode::render()). Its hover state (sanilwb_hover_bg_color/sanilwb_hover_color) falls back to the theme's primary/background colors when left unset, so a topic-linked button always has a visible hover effect out of the box regardless of Clickable. Label has no required flag — despite always rendering something ('Read More' fallback), marking it required would incorrectly block saving whenever Link to Topic is on and Label is empty, since field validation does not account for showIf visibility.

Style applies to the button's own tag, not a generic wrapper — this is why Button needs a target redirect at all. Every other block-level widget type fills its div, so styling the generic .sanilwb-widget-{uid} row wrapper looks identical to styling the widget's own rendered element. Button is inline-block (or content-sized even when set to block), so wrapper-level styling would visibly detach from the actual <a>/<span>. button.styles.json redirects every section to .sanilwb-button instead — the same mechanism any widget's own custom target uses (see Style Field JSON Reference), nothing Button-specific:

  • SANILWB_Button_Shortcode::render() has no CSS-building code of its own at all. render_widgets() (class-sanilwb-frontend-renderer.php) builds every real, saved-page widget's CSS before calling the shortcode for markup — looping over SANILWB_Style_Field_Targets::get_widget_style_targets('button') and calling collect_widget_target_css()/collect_widget_target_hover_css() (thin wrappers around SANILWB_CSS_Compiler::build_widget_target_rule_set()/build_widget_target_hover_rule_set()) for each, scoped to .sanilwb-widget-{uid} .sanilwb-button — a normal descendant selector, since .sanilwb-widget-{uid} is a real wrapper div one level outside Button's own rendered tag. (An earlier version of this pipeline had a separate build_self_contained_style_block() function building an inline <style> block per-shortcode for a hand-typed [sanilwb type="button"] shortcode or the AJAX canvas-preview endpoint, using a same-tag compound selector since neither of those contexts has a real wrapper div. That function was retired — hand-typed shortcode usage isn't supported, and the AJAX-preview case is already covered by the editor's own buildCanvasLiveCss.js/computeWidgetItemWrapperStyle.js — so Button's own render() no longer builds any CSS itself, full stop.)
  • render_widgets() never runs the generic wrapper-level collect_element_css() pass for Button — SANILWB_Style_Field_Targets::widget_has_root_target('button') returns false, since every section in button.styles.json redirects away from root, so the dispatch loop already skips it generically.
  • The JS editor preview mirrors this: renderButtonWidget() calls buildButtonStyleAttr(), which resolves buildElementStyles(getWidgetStyleFields('button'), values, device, ALLOWED_STYLE_TARGETS.button).get('.sanilwb-button') — the same per-target resolution any widget's own render function uses for its custom target — and folds the result into the button's inline style="" attribute. The one Button-specific addition on top: when Layout Type is genuinely untouched, display defaults to inline-block (matching the button's natural look) rather than being left unset.

Gradient fields are not sanilwb_-prefixed. The Gradient section of SHARED_STYLE_FIELDS uses the literal names gradient-angle, gradient-color-1, gradient-color-2, plus the internally-managed gradient-color-1-stop/gradient-color-2-stop — hardcoded inside the shared GradientStopsField.jsx component, so they're never renamed per-widget. This is safe because each widget instance owns its own isolated values object.

gradient-angle is stored with its unit suffix attached (e.g. "45deg"), not a bare number. It's a NumberField locked to unit: 'deg' (not a switchable units array), and NumberField.jsx always calls formatUnitValue() on change regardless of whether the unit is switchable. PHP casts with a plain (int) (which stops at the first non-numeric character — (int) "45deg" === 45), and JS uses parseInt() for the same reason.

Default styles pull from Theme Options, not a hardcoded color. defaultValues sets sanilwb_bck_color: 'var(--color-primary)' and sanilwb_color: 'var(--color-background)' — CSS custom property references, not literal hex values, so a freshly-added Button always matches whatever the site owner has configured as their Primary/Background color in Theme Options, and updates automatically if they change it later. Default padding (sanilwb_padding_top/bottom: '6', sanilwb_padding_left/right: '15') and default border radius (sanilwb_border_radius_top_left/top_right/bottom_right/bottom_left: '5') are stored as bare numbers (implicitly px via resolve_px_value()/parseUnitValue()).

Files: admin/assets/js/src/page-builder/config/widgets/carouselNav/carouselNav.styles.json · includes/class-sanilwb-style-field-targets.php (WIDGET_STYLES_JSON_FILENAMES, widget_has_root_target()) · includes/class-sanilwb-css-compiler.php (build_carousel_nav_wrapper_rule_set()) · includes/class-sanilwb-frontend-renderer.php (render_widgets()'s generic per-target CSS pipeline) · includes/shortcodes/class-sanilwb-carousel-nav-shortcode.php · admin/assets/js/src/page-builder/utils/buildCanvasLiveCss.js (findFieldTarget(), scopeCompoundTarget()).

Carousel Nav's own "own tag" is really two child elements — .sanilwb-carousel-prev/.sanilwb-carousel-next — not its wrapper. carouselNav.styles.json keeps Size and Position on the default root (the wrapper is genuinely sized/positioned as its own element) but redirects Background/Gradient/Border/Box Shadow/Transitions/Spacing to the real compound target ".sanilwb-carousel-prev,.sanilwb-carousel-next", plus its own ownFields (Button Size, Icon Size, Icon Color, Fill Color) targeting either that same compound selector or svg.

Because Size/Position stay on root, widget_has_root_target('carousel_nav') returns true — but the wrapper still must never receive Background/Typography/etc. (which belong only on the two buttons), so its root-level pass goes through SANILWB_CSS_Compiler::build_carousel_nav_wrapper_rule_set() — a narrow escape hatch that builds only Size/Position declarations — instead of the generic collect_element_css() blanket pass every other root-target widget gets. The two buttons' own CSS (everything redirected away from root, plus every ownFields entry) comes from render_widgets()'s normal generic per-target loop — the exact same collect_widget_target_css()/collect_widget_target_hover_css() calls Button uses, scoped to .sanilwb-widget-{uid} .sanilwb-carousel-prev,.sanilwb-widget-{uid} .sanilwb-carousel-next — no widget-specific CSS-building code for the buttons themselves, only for the wrapper's own Size/Position via build_carousel_nav_wrapper_rule_set() above.

SANILWB_Carousel_Nav_Shortcode::render() still builds a <style> block of its own, but only for the wrapper's Size/Position (build_carousel_nav_wrapper_rule_set()), gated by the same skip_self_contained_css flag as before — used only for the AJAX canvas-preview endpoint, since render_widgets() already collects this into the cached CSS file for a real saved page. The two buttons' own CSS used to come from a matching build_self_contained_style_block('carousel_nav', $uid, $args, null) call in this same gated block — that function was retired (see the Button section above), so the AJAX preview's own two-button CSS in this shortcode's output is gone; the editor canvas still shows the buttons correctly via buildCanvasLiveCss.js's independent live-CSS overlay, unaffected by this shortcode's own output.

A widget's .styles.json filename is always mapped explicitly in both languages, never derived from the type string. JS imports carouselNav.styles.json (camelCase) and maps it to the snake_case key carousel_nav in WIDGET_STYLES_JSON (config/widgets/index.js). PHP's SANILWB_Style_Field_Targets::WIDGET_STYLES_JSON_FILENAMES mirrors that same map explicitly. Every multi-word widget type needs its own entry in both maps — there is no algorithmic derivation from the type string in either language.

Hover for a real custom target resolves the same way resting-state does — no separate map. findFieldTarget() (JS) / the target argument to build_widget_target_hover_rule_set() (PHP) resolve wherever a field's target actually points, generically, for any widget type. scopeCompoundTarget()/scope_compound_target() handle a compound target like Carousel Nav's two button classes by scoping each comma-separated part independently, and appendHoverPseudo()/append_hover_pseudo() append :hover to each part independently too — never to the whole joined selector string at once, which would only ever land on the last part. Any widget with a compound target must go through these two helpers rather than concatenating the selector by hand.

Media Field

File: admin/assets/js/src/shared/components/fields/MediaField.jsx, registered as field type image in FormFields.jsx.

Used by the Image widget's sanilwb_attachment_id field. Unlike the older ImageUrlField (field type not registered in FormFields.jsx, used directly by Site Layout instead), MediaField stores a WordPress attachment ID, not a URL — this lets the PHP handler call wp_get_attachment_image() for a responsive srcset at the widget's chosen size and fall back to the attachment's own alt text. It resolves a preview thumbnail via wp.media.attachment(id).fetch() on mount (existing widget) or from the picker's own selection data (new pick).

Requires wp_enqueue_media(). Both Page Builder enqueue paths call it — SANILWB_Admin_PageBuilder::enqueue_scripts() for the classic metabox screen, and inline in public/hooks.php's ?sanilwb_editor=1 template_redirect handler for the frontend full-screen editor — without it, wp.media is undefined and the picker button silently does nothing.


Dynamic Data (Variable Picker: Site Variables + ACF/SCF)

Files: includes/interface-sanilwb-dynamic-data-source.php · includes/class-sanilwb-dynamic-data-source-site-variable.php · includes/class-sanilwb-dynamic-data-source-acf-field.php · includes/class-sanilwb-dynamic-data-registry.php · includes/class-sanilwb-ajax.php (discover_loop_acf_fields(), resolve_acf_token(), resolve_acf_token_across_loop()) · admin/assets/js/src/shared/utils/variableTokens.js · admin/assets/js/src/shared/components/VariablePicker.jsx · admin/assets/js/src/shared/components/VariableTokenPill.jsx · admin/assets/js/src/shared/components/VariableTokenSettingsPopover.jsx · admin/assets/js/src/page-builder/hooks/useDynamicDataAcfFields.js · admin/assets/js/src/page-builder/utils/renderWidgetPreview.js (resolveDynamicDataValue(), resolveAcfFieldToken(), sanitizeHrefValue()) · includes/class-sanilwb-frontend-renderer.php (render_widgets()'s token-resolution pass)

Any text/textarea/url-type Content-tab field can be linked to a Dynamic Data source instead of holding a literal value, through a pluggable registry (SANILWB_Dynamic_Data_Registry) rather than one hardcoded system. Two sources ship today: Site Variables (Theme Options → Variables — see Theme Options: Variables) and ACF/SCF custom fields — only active when the ACF/SCF plugin is installed (function_exists('get_field_objects')); this plugin never depends on it, it's purely an optional, pluggable source.

Token formats

Token Source Meaning
{{sanilwb_var:N}} Site Variable N is the variable's integer id. Unchanged since Theme Options first shipped Variables.
{{sanilwb_acf:field_name}} ACF field Default representation (raw id/scalar value), no fallback.
{{sanilwb_acf:field_name:representation:fallback_b64}} ACF field Extended form — see Key/Fallback below. representation empty = default. fallback_b64 is base64, same Unicode-safe encoding buildWidgetShortcode.js already uses for shortcode attribute values (btoa(unescape(encodeURIComponent(text))) JS-side, plain base64_decode() PHP-side).

Two entirely different resolution mechanisms, depending on field context

This is the single most important thing to understand here — a token does not resolve the same way everywhere:

  • Style-context fields (pixel/unit values — spacing, size, font size, etc.) resolve via a CSS custom property: a Site Variable token becomes var(--sanilwb-var-N), and the browser resolves it from theme-vars.css (see Theme Options: CSS Variables Output on the Frontend). ACF fields are never offered here at all — isAcfFieldTypeCompatible()'s 'unit' branch always returns false, since ACF has no concept of a field's CSS unit.
  • Content-context fields (Heading/Paragraph text, Button label, any url-type field) resolve as a real string substitution at render time — this is what SANILWB_Dynamic_Data_Registry and resolveDynamicDataValue() actually do:
  • Real frontend (PHP): SANILWB_Frontend_Renderer::render_widgets() calls SANILWB_Dynamic_Data_Registry::instance()->resolve_all_tokens($string_val, ['post_id' => get_the_ID()]) on every scalar widget field value, before building the widget's shortcode — the single choke point every widget field passes through, so no per-widget-type shortcode class needs its own resolution call.
  • Editor canvas (JS): renderWidgetPreview.js's resolveDynamicDataValue() — a Site Variable token resolves synchronously from window.PageBuilderData.themeVariables (already client-side, no fetch); an ACF token resolves via a batched sanilwb_resolve_acf_token AJAX call (see below). This is why renderHeadingWidget()/renderParagraphWidget()/renderButtonWidget() are async functions.

Where tokens are (and aren't) resolved in the canvas today. Only fields whose value renders as visible text — Heading/Paragraph text, Button label — resolve a token in the canvas preview. URL-type fields (Heading/Button/Icon/Image's link fields) do not: a linked token shows its raw {{sanilwb_acf:...}} string as the literal href in the editor, even though the real published page resolves it correctly (PHP's resolve_all_tokens() covers every scalar field, URL fields included). Known, deliberately deprioritized gap — only worth fixing if it starts changing what's visually rendered in the canvas, which today it doesn't (the link still works once published). Fixing it would also need renderIconWidget()/renderImageWidget() to receive postId/loopOptions at all — today they only get device, or nothing.

Variable Picker UI

VariablePicker.jsx is the icon button beside every token-capable field. Its popover shows one collapsible (single-open-at-a-time accordion, via the shared FormSection.jsx) section per available source. Each entry shows only its name/label — never a resolved value/preview — since a bound field's real value is ambiguous inside a Loop (the same widget config renders N different values per matched item), matching Elementor's own editor-UI approach.

Entries are filtered per field by isAcfFieldTypeCompatible(acfFieldType, filterType):

  • filterType: 'string' (plain text/textarea fields) — text-like ACF types: text, textarea, wysiwyg, select, taxonomy, radio, email, true_false, url.
  • filterType: 'url' (Button/Heading/Icon/Image/Video's link fields) — every ACF field type is offered, unrestricted. A Taxonomy field's Link Key or a Post Object field's Permalink Key are just as valid a link source as a plain url-type field — restricting by ACF's own field type would hide, e.g., a topic Taxonomy field from a Heading's URL field, even though topic:link is exactly what you'd want there.
  • filterType: 'unit' (Number/InputGroup pixel/size fields) — no ACF field is ever offered; ACF has no CSS-unit concept.

A url-type field's value is not format-validated. Dialog.jsx's validateFields() used to reject anything not matching ^https?://, which also rejected a linked Dynamic Data token (obviously not a URL by that pattern) — removed entirely. A url field now accepts a token, a bare fragment (#target-id), a relative path, or literally anything typed.

A dangerous scheme is still blocked where it becomes a real href, just not by input validation. The real frontend was always safe — every href in SANILWB_Frontend_Renderer/the widget shortcode classes goes through WordPress's own esc_url(), which strips javascript:/data:/etc. via its own protocol allow-list. The canvas preview builds href attributes as plain strings (escAttr() only escapes HTML-special characters, not URL schemes), so renderWidgetPreview.js has its own equivalent — sanitizeHrefValue() — applied to Heading/Button/Icon/Image's URL fields, blanking any scheme not on a small allow-list (http, https, ftp, mailto, tel, ...) while passing fragments/relative paths through untouched.

ACF field discovery

SANILWB_Dynamic_Data_Source_ACF_Field::get_entries() reads via ACF/SCF's own get_field_objects($post_id, true, false)$load_value = false since this is a names-only listing (Variable Picker never shows a value preview), so there's nothing to gain from loading real field data. entries_from_field_objects() (public + static, pure logic) keeps only single-value fields — is_single_value_field() excludes anything inherently multi-value (repeater, gallery, relationship, flexible_content, group) or a normally-single-value type with ACF's own "multiple" setting on (e.g. an Author field configured as a multi-select User field) — decided from the field's config alone, never its value.

Loop-aware field discovery. A widget nested inside a Loop-enabled div sees the Loop's own queried post type/taxonomy/role's fields, not the outer page's — useDynamicDataAcfFields() (page-builder/hooks/useDynamicDataAcfFields.js) walks up to the nearest Loop ancestor via findLoopAncestorNode() (treeTraversal.js) and, if found, calls sanilwb_discover_loop_acf_fields instead of reading the static window.PageBuilderData.acfFields list. That AJAX handler (SANILWB_Ajax::discover_loop_acf_fields()) runs the Loop's real query (SANILWB_Shortcode_Helpers::resolve_loop_iteration_plan()), samples up to 20 matched posts/terms/users, and merges their field names via the same entries_from_field_objects() used for the single-post case — addressed via ACF's own 'term_' . $id / 'user_' . $id convention for non-post contexts, so no separate location-rule-matching logic is needed.

Resolving an ACF token across a whole Loop without N separate requests

Every clone of the same Loop shares the enclosing div's own raw options object — LoopContext.jsx's LoopProvider exposes it as loopOptions, and resolveAcfFieldToken() (renderWidgetPreview.js) passes it straight through as a cachedPostAjax() param, JSON-stringified. Since every clone sends byte-identical params, cachedPostAjax()'s own module-level request cache (keyed by action|JSON.stringify(params)shared/utils/postAjax.js) collapses what would otherwise be N requests (one per clone) into a single real network call, with the response (an array of {id, value}, one per matched item) shared by every clone. SANILWB_Ajax::resolve_acf_token() mirrors this server-side: given loop_options it dispatches to resolve_acf_token_across_loop(), which runs the same query plan and resolves the field against every matched item at once; given a bare post_id instead (outside a Loop), it resolves just that one post — but always returns the same array shape either way (0, 1, or N entries), so the JS side never needs to special-case which mode it's in.

Key (representation) + Fallback — Elementor-style per-field settings

Opened via a gear icon on an ACF-linked VariableTokenPill (VariableTokenSettingsPopover.jsx, modeled on UnitPicker.jsx's portal + two-pass-position pattern — the smallest existing example of that pattern in this codebase). Verified against Elementor's own documented ACF integration before building, to match scope rather than under- or over-build it.

  • Key — which representation of a reference field to show instead of its raw id: Taxonomy → Name/Slug/Link, User → Display Name/Email, Post Object → Title/Permalink (ACF_REPRESENTATION_OPTIONS_BY_TYPE in variableTokens.js). Only shown when the linked field's own type has entries there — a plain field type (text, url, number, ...) has no Key concept, so the popover shows Fallback only. Defaults to id — the plain, unsuffixed token form, and today's-exact behavior until a user opts in.
  • Fallback — text shown when the resolved value comes back empty. Applies to any ACF field type, not just reference ones.

Resolution — SANILWB_Dynamic_Data_Source_ACF_Field::resolve_display_value(array $field, string $representation) — switches on $field['type'], resolving via get_term()/get_userdata()/get_post() only for a non-default representation. The default/id representation never calls any of those — it reads the id straight off whatever shape get_field_object()'s value already arrived in (extract_reference_id() handles a scalar id, a full object, or an array, matching whichever ACF Return Format the field happens to be configured with), keeping the common, unconfigured path at zero added queries — both for a single post and for every clone inside a Loop on the real frontend.

resolve_in_text()'s regex captures the optional :representation:fallback_b64 suffix and applies the decoded fallback only when the resolved value comes back empty. SANILWB_Ajax::resolve_acf_token()/resolve_acf_token_across_loop() gained a matching representation POST param and switched from get_field() to get_field_object() — fallback itself is applied client-side in resolveDynamicDataValue() instead, since the JS side already holds the full parsed token; the AJAX response only ever carries the raw resolved value.

Query cost inside a Loop, when a non-default Key is chosen. Choosing e.g. Name for a Taxonomy field costs one extra get_term() lookup per matched item — for a Loop of N items, that's up to N extra queries, riding on the per-clone render pass that's already happening, not a new parallel query system. This is identical to the cost a hand-written WP_Query loop in the theme calling get_term() per item would have — not reduced further via batch-fetching (e.g. one get_terms(['include' => [...]]) call for the whole Loop up front); a known, accepted limitation, not built.

Explicitly out of scope / deferred

Retiring the Button widget's hardcoded "Link to Topic" toggle (see Button Widget Styling above) — a separate, later effort, since it's the last piece still depending on get_field() directly instead of going through this registry. Also deferred: a Loop "ACF Repeater" Query Type + a generic ambient "ACF Field" widget, multi-author via Loop's Users Query Type, Post Meta as a third Data Source, batched (non-per-item) term/user/post lookups for a non-default Key inside a Loop.


Canvas Preview Rendering

Files: admin/assets/js/src/page-builder/components/canvas/CanvasDiv.jsx admin/assets/js/src/page-builder/components/canvas/CanvasWidgets.jsx admin/assets/js/src/page-builder/utils/renderWidgetPreview.js admin/assets/js/src/page-builder/utils/patchWidgetLive.js

CanvasDiv is a pure visual renderer with no toolbars, overlays, or DnD — it recurses into itself for kind: 'div' children and renders CanvasWidget for kind: 'widget' children, at any depth. A root-level div gets the two-element outer/.container wrapper described under Frontend Rendering below; a non-root div wraps its own children directly with no such split.

CanvasWidget resolves each widget's preview HTML through one of two paths, chosen per widget type:

  • JS-renderedheading, paragraph, image, icon, button, post_title, archive_title, featured_image, excerpt, term_name. renderWidgetPreview() builds the HTML directly in JS from widget.values (and, for the ambient-context widgets, a per-session cached fetch of the post being edited via sanilwb_get_post_preview — or, inside a Loop, that item's own preview_data handed down via context instead of a fetch at all; see Loop). No sanilwb_pb_render_widget AJAX call happens for these types on a settings change. A Heading/Paragraph/Button text field also resolves any linked Dynamic Data token here (async, since an ACF token needs a fetch) — see Dynamic Data above.
  • AJAX-renderedpost_content, template_reference, each for its own reason: post_content depends on the real the_content filter chain (shortcodes, blocks, embeds), which can't be reimplemented in JS; template_reference depends on the referenced template's own schema_json, rendered live by the same PHP renderer a real page uses (see Dynamic Template Widget above) — reimplementing that whole tree-walk in JS would duplicate the entire PHP render pipeline. renderWidgetPreview() returns null for both and CanvasWidgets.jsx falls back to the real sanilwb_pb_render_widget/do_shortcode() round trip (ajax_render_widget() in public/class-sanil-website-builder-public.php).

isJsRenderedWidgetType(type) (exported from renderWidgetPreview.js) is the source of truth for which list a type is in — CanvasWidgets.jsx checks it before deciding whether to show the loading spinner.

Loading spinner only applies to the AJAX path. Earlier, every widget type went through the same setLoading(true) → 500ms debounce → fetch pipeline on any settings change, including JS-rendered types that never touch the network — this made the canvas flash a spinner on every keystroke in, e.g., the Heading widget's text field, even though the render was already complete synchronously. CanvasWidgetImpl's effect now checks isJsRenderedWidgetType(widget.type) right after the domPatch branch: if true, it calls renderWidgetPreview() immediately (no debounce, no setLoading(true)) and just updates html when it resolves. Only post_content/template_reference still go through the debounce + spinner, since those are the types that actually wait on a server response.

classifyValuesChange() (in patchWidgetLive.js) is a separate, narrower optimization scoped to widget types that define structuralFields in WIDGET_TYPES — today that's post_content and template_reference, both of which distinguish style-only edits (patched live via CSS/inline style, no AJAX at all) from edits that require a full AJAX re-render. It returns 'unsupported' for every widget type that doesn't define structuralFields at all — every JS-rendered type. Post Content's structuralFields is an empty array — every one of its fields is style-only, so it can only ever classify as 'none', never 'structural', once past the unavoidable first-mount fetch. Dynamic Template's structuralFields is ['sanilwb_template_id', 'sanilwb_template_overrides'] — only those two actually change what needs fetching from PHP (which template, and which of its widgets are overridden); every Style-tab field (Border, Box Shadow, Spacing, Position — see Dynamic Template Widget's own reduced Style tab) classifies as 'none' and skips the AJAX round trip entirely, patched instantly via the widget's own inline style (buildElementStyles()) the same as any JS-rendered widget. Before this was added, Dynamic Template had no structuralFields config at all, so classifyValuesChange() returned 'unsupported' for every change including style-only ones — CanvasWidgets.jsx treats 'unsupported' the same as 'structural', so every single Style-tab edit triggered a full AJAX round trip for no visual benefit; this was a real, since-fixed bug, not original design.

Re-render scope: updateWidget() in the store rebuilds only the path from the root down to the edited widget (the clone-and-mutate helpers in treeTraversal.js return the same object references for any branch of the tree that doesn't contain the target). Combined with React.memo on CanvasDiv and CanvasWidget, only the edited widget (and the divs on its ancestor path) actually re-render — sibling subtrees bail out of memo without re-executing.


Save / AJAX

AJAX action: sanilwb_pb_save Nonce: window.PageBuilderData.pbSaveNonce Handler: SANILWB_Admin_PageBuilder::ajax_pb_save() (admin/class-sanilwb-admin-page-builder.php)

Save logic lives entirely in SaveButton.jsx. The button is disabled only while a save is already in flight or during the brief "Saved" confirmation — not based on whether there are unsaved changes; clicking Save with nothing changed simply re-saves the current state. When clicked while enabled, it serializes the store via serializeToJson() and POSTs to window.PageBuilderData.ajaxUrl. The button shows three states: idle (label "Save") → saving (label "Saving…") → saved (label "Saved", 1.5s) → idle.

In-flight edit guard: handleSave() captures the store's sections array reference before serializing and POSTing. Every mutating store action replaces sections with a new array reference (see the copy-on-write tree helpers above), so if the user keeps editing while the request is in flight, that reference changes. When the response comes back, markSaved() (which clears hasUnsavedChanges) is only called if the reference still matches what was captured — otherwise the edits made during the request were never included in the JSON that was POSTed, and the store is correctly left showing unsaved changes instead of silently discarding them.

The Close button (handleClose in PageBuilder.jsx) shows a window.confirm dialog before navigating away when hasUnsavedChanges is true.


Frontend Rendering

Files: public/class-sanil-website-builder-public.php — thin facade: constructor, init() (registers hooks), enqueue_scripts(), bust_widget_cache(), ajax_render_widget(), and a one-line delegator for render_page_builder_content() includes/class-sanilwb-frontend-renderer.php — the actual div/widget render loop (SANILWB_Frontend_Renderer), Blueprint-protected since SANILWB_Site_Layout_Renderer calls it for every header/footer/single/page/archive/404 render includes/class-sanilwb-shortcode-handler.php + includes/shortcodes/[sanilwb] shortcode dispatch: SANILWB_Shortcode_Handler::process_shortcodes() routes on type to one render() class per widget type includes/class-sanilwb-css-compiler.php — stateless CSS declaration builders (SANILWB_CSS_Compiler), public static, shared by the render loop and the shortcode classes includes/class-sanilwb-css-cache.php — generates and enqueues the static cached CSS files described in CSS Caching below public/helpers.php

This split (frontend-renderer / shortcode-handler+shortcodes / css-compiler / facade) is the result of Phase 4 of the page-builder refactor — all four pieces used to live as ~50 methods on one Sanil_Website_Builder_Public class. The facade still exists and keeps the same public method signatures (render_page_builder_content(), ajax_render_widget(), etc.) so nothing outside this file needed to change.

SANILWB_Frontend_Renderer::render_page_builder_content() reads the stored sanilwb_data post meta, decodes it into an array of root divs, and calls render_div($root_div, true, $root_div->options) on each — true marking it as a root div (see below), and its own options threaded down as $root_opts through every recursive call so sanilwb_font_color_scheme always resolves to the top div's value for every widget under it, regardless of nesting depth.

render_div() recurses via render_children(), which dispatches each child by its kind: a 'widget' child is handed to render_widgets() (which builds and runs a do_shortcode('[sanilwb type="heading" ...]') call, stripping the sanilwb_ prefix from each key so SANILWB_Shortcode_Handler::process_shortcodes() receives plain attribute names), a 'div' child recurses back into render_div().

Root vs. non-root div markup. A root-level div gets a two-element wrapper: a full-width outer element (appearance + flex-child sizing only, for full-bleed background painting — row-style/container class names are preserved exactly because public/js/sanilwb-carousel.js's .closest() lookup depends on finding one of them as a boundary marker) around a width-constrained .container that carries this div's own flex-container layout declarations (direction/align/justify/wrap/gap) and holds the actual children. That split exists because flex-container properties only have a visible effect on the element that directly wraps the children they arrange — the outer wrapper's only child is .container, so a root div's own direction/gap have to live there instead. A non-root div has no such split: it directly wraps its own children already, so one element carries appearance, sizing, and layout declarations together.

Ambient post context in the AJAX canvas preview

Sanil_Website_Builder_Public::ajax_render_widget() (action sanilwb_pb_render_widget) sets up post context for dynamic widgets (Post Title, Excerpt, Post Content, etc.) so they preview correctly against the post being edited:

if ( $post_id ) {
    global $post;
    $post = get_post( $post_id );
    if ( $post ) {
        setup_postdata( $post );
    }
}

The global $post; declaration is required. In modern WordPress, setup_postdata() alone does not set $GLOBALS['post'] — it only sets the auxiliary globals ($authordata, $page, $more, etc.) and fires the the_post action; $GLOBALS['post'] is set by WP_Query::the_post()'s own explicit global $post; assignment. Without global $post; here, $post = get_post($post_id) creates a method-local variable, setup_postdata() has no visible effect on the global, and every ambient-context template tag (get_the_title(), get_the_excerpt(), get_the_content(), get_permalink()) silently returns empty — this was a real bug found while building the dynamic widgets above, not a hypothetical.

CSS Caching

Files: includes/class-sanilwb-css-cache.php (SANILWB_CSS_Cache) · includes/class-sanilwb-frontend-renderer.php (next_el_class(), $suppress_css_collection) · includes/class-sanilwb-site-layout-renderer.php (get_active_layout_id())

All spacing, border, and layout styles (margin, padding, border-radius, border-width, border-style, border-color, flex layout) are written to a static, cached CSS file — one per page (or Site Layout entry), one per Template — enqueued as an ordinary <link rel="stylesheet"> in <head>. This replaced an earlier design where the same CSS was rebuilt from scratch and echoed as a single <style id="sanilwb-page-styles"> block in <body> on every single page view; that approach couldn't be browser/CDN-cached and grew with widget count on every request.

Single source of truth, no fallback. There is exactly one way CSS reaches the page — the cached file — and exactly two ways that file gets produced: a save hook, or a future manual "regenerate" action (the underlying generate_page_css_file()/generate_template_css_file() are plain public statics, ready for that, but no UI for it exists yet). If a file is missing (never generated, or a write failed), that region renders with no CSS at all — nothing silently regenerates it inline or on the fly. This is a deliberate choice: a dual-path system (cached file + inline fallback) was considered and rejected as a second, harder-to-reason-about source of CSS.

Real renders never collect CSS. SANILWB_Frontend_Renderer::$suppress_css_collection defaults to true — every one of the collect_*_css() methods early-returns instead of building a CSS string, on every real page view. The only place that ever sets it to false is SANILWB_CSS_Cache's own generation helper, which renders a fresh SANILWB_Frontend_Renderer instance purely to capture get_pending_css()'s result, discarding the HTML. This is a bigger, unconditional CPU win than the old "build fresh every time" approach — not just tidier <head> markup.

Generation is save-triggered:

Save action Handler What gets (re)generated
Classic metabox save SANILWB_Admin_PageBuilder::save_metabox() SANILWB_CSS_Cache::generate_page_css_file( $post_id )
Full-screen editor Save SANILWB_Admin_PageBuilder::ajax_pb_save() same
Template create/update SANILWB_Ajax::save_template() SANILWB_CSS_Cache::generate_template_css_file( $id )
Template duplicate SANILWB_Ajax::duplicate_template() same, for the new id
Template hard delete SANILWB_Ajax::delete_template() SANILWB_CSS_Cache::delete_template_css_file( $id )
Template trash/restore nothingSANILWB_DB::get() has no deleted_at filter, so a trashed Template still renders correctly, and its cache file must stay valid throughout

Files live at {uploads}/sanilwb/page-css/{post_id}.css and {uploads}/sanilwb/template-css/{template_id}.css, versioned by a dedicated _sanilwb_css_version post meta (pages) or a css_version DB column (Templates) — not post_modified/updated_at, both of which bump on unrelated saves and would force pointless regeneration.

A Template's own CSS must be placement-independent for this caching to be valid. Every div's scoped class name (sanilwb-el-N) used to come from a single page-wide $el_counter — including when a Dynamic Template widget's referenced schema rendered through the same renderer instance — so the same Template produced different class names depending on how much else had already rendered on whatever page it was placed on, making a single shared cached file impossible. SANILWB_Frontend_Renderer::next_el_class() fixes this: rendering a Template's own nodes (SANILWB_Template_Reference_Shortcode::render() sets active_template_id_for_css) draws from a separate, always-reset-to-0 $template_el_counter instead, producing sanilwb-tmpl-{id}-el-N classes that are identical for that Template regardless of placement or nesting depth. This is unrelated to the uid-scoping mechanism described above (which exists for DOM identity/content overrides, not CSS caching) — both apply to the same widget simultaneously, solving different problems.

Enqueue is read-only. SANILWB_CSS_Cache::enqueue_page_builder_css() (hooked to wp_enqueue_scripts) resolves which real post_id(s) will render this request — a page's own page-pagebuilder.php-templated content, or (falling through) a resolved Site Layout entry via SANILWB_Site_Layout_Renderer::get_active_layout_id(), reused rather than re-derived — then recursively scans that content's sanilwb_data/schema_json for template_reference widgets (including Templates nested inside other Templates) and enqueues each distinct one's own cached file. A missing file is skipped silently; nothing gets lazily generated at enqueue time.

Breakpoints:

Constant Value Used for
SANILWB_BP_TABLET_MIN 768px min-width of tablet media query
SANILWB_BP_TABLET_MAX 1199px max-width of tablet media query
SANILWB_BP_MOBILE_MAX 767px max-width of mobile media query

Responsive field values (e.g. sanilwb_margin_top__tablet) are read with the __tablet / __mobile suffix automatically by SANILWB_CSS_Compiler's declaration builders. If a responsive value is not set, no rule is emitted for that breakpoint.

No inline styles: divs and widgets carry only their scoped class — no style="" attribute. All visual rules live exclusively in the cached file (or the Dynamic Template widget's own wrapper rules, which land in the page's own cached file via the ordinary .sanilwb-widget-{uid} selector path — a completely separate selector namespace from a Template's own sanilwb-tmpl-{id}-el-N rules, so the two never collide).

Known gap: Blueprints' page-apply/rollback-snapshot writes and the legacy Template Builder list page's bulk trash/restore/delete action write sanilwb_data/Template rows outside the save hooks above, so they don't trigger regeneration yet — flagged as follow-up work, not fixed.


Loop — repeating a container per query match

Files: includes/class-sanilwb-frontend-renderer.phprender_div_or_loop_clones(), detect_needed_preview_groups(), render_loop_editor_preview() includes/shortcodes/class-sanilwb-shortcode-helpers.phpresolve_loop_iteration_plan() and the query-arg builders public/helpers.phpsanilwb_build_post_preview_data() public/class-sanil-website-builder-public.phpajax_render_loop() admin/assets/js/src/page-builder/config/widgets/structuralDialogTabs.js — Loop tab fields admin/assets/js/src/page-builder/components/canvas/LoopChildGroup.jsx admin/assets/js/src/page-builder/components/canvas/useLoopPreview.js admin/assets/js/src/page-builder/context/LoopContext.jsx admin/assets/js/src/page-builder/context/CloneIdentityContext.jsx

A div's Style/Advanced tabs are joined by a third Loop tab (sanilwb_loop_enabled and the other sanilwb_loop_* fields — see structuralDialogTabs.js), shown only in context: 'page' — Loop is a per-page dynamic query, so it makes no sense on a template reused across many different pages (the same reasoning that keeps data-fetching widgets like Post Content out of that context). When enabled, the div stops being a single element and becomes a container repeated once per matched item — one fully independent clone per match, never one shared wrapper holding N repeats.

Query configuration. Context (New Query / Current Query) decides everything else. Current Query always means "posts matching this page's own main query" — WordPress has no equivalent main query for terms/users, so choosing it skips straight to the Total Items/Offset/Order overrides. New Query exposes Query Type (Post Type / Terms / Users), which decides which query engine actually runs: WP_Query, WP_Term_Query, or WP_User_Query — see SANILWB_Shortcode_Helpers::resolve_loop_iteration_plan() for the mapping. Nested loops are not supported (v1) — a div already sitting inside a Loop-enabled ancestor never even sees the Loop tab, gated by the synthetic sanilwb_loop_ancestor_active value (isDivInsideLoopAncestor() in treeTraversal.js); this is enforced again server-side (SANILWB_Ajax::schema_has_content_not_allowed_in_context()), not just hidden client-side. It's also enforced at drag-and-drop move time (fixed 2026-08-20) — moveNode() (see Store above) rejects dragging a Loop/Carousel-enabled div so it becomes a descendant of another one; before that fix, only manually enabling the Loop/Carousel toggle was guarded, so dragging an already-enabled div into another one silently produced an unsupported nested structure.

PHP rendering — one independent clone per match, not a shared wrapper. render_div_or_loop_clones() resolves the matched item ids via resolve_loop_iteration_plan(), then calls the ordinary (loop-agnostic) render_div() once per match, rewinding $el_counter/$suppress_css_collection between calls so N identical clones only ever emit one copy of their CSS. render_children()/render_page_builder_content() call this instead of render_div() directly whenever a div has Loop enabled. An earlier version wrapped every repeat inside one persistent, non-repeating outer element (.pbloop-item) — that broke grid/columns (CSS landed on the one wrapper, not the repeats), per-repeat background/border (painted once instead of per item), and a parent's own flex-gap (which only ever saw one child, the wrapper). Fixed by moving loop expansion to the call site that renders a list of children, keeping render_div() itself loop-agnostic.

Editor rendering. LoopChildGroup.jsx is the shared expansion component (used by both CanvasDiv.jsx's children map and Canvas.jsx's root map): it fetches matched items via useLoopPreview() (one batched AJAX call, action sanilwb_pb_render_looprender_loop_editor_preview()), then renders one real, live, fully-interactive CanvasDiv per item — every item is editable, not just the first. A 0-match (or still-loading) Loop renders exactly one dimmed stand-in clone so its widgets stay editable even with nothing to preview against. CloneIdentityContext.jsx solves the dnd-kit id collision this creates — every clone shares the same underlying schema/widget id, but dnd-kit needs one DOM instance per id, so only the first clone keeps its real draggable identity; every other clone gets a unique sortable id with disabled: { draggable: true, droppable: false } — the object form, not a plain boolean. useSortable's boolean form disables both draggable and droppable, which would make every widget/div inside a 2nd+ clone unreachable as a drop target (fixed 2026-08-20 in CanvasWidgets.jsx/CanvasDiv.jsx); the object form disables only dragging, so non-primary clones stay valid drop targets.

Ambient widget data is batched into that same one request. A Loop item's Post Title/Excerpt/Featured Image/Button-Link-to-Topic widgets need that matched post's data — rather than each clone fetching its own post individually (which fans out into hundreds of requests on a page with many Loop containers), render_loop_editor_preview() attaches each item's data directly to the batched response as preview_data. detect_needed_preview_groups() scans the Loop's own widget tree first so only the groups its actual widgets use get built at all (sanilwb_build_post_preview_data() in public/helpers.php, shared with the Preview Post Picker's own full-data fetch) — a Loop with only a Post Title widget never pays for the excerpt/image/topic DB lookups it doesn't need. On the editor side, preview_data rides down through LoopProvider's previewData prop (LoopContext.jsx) rather than the shared cachedPostAjax() cache — two different Loops can reference the same post while needing different fields, and priming a globally-keyed cache with one Loop's partial answer would leak into the other Loop's read of the "same" post; per-subtree context avoids that entirely. renderWidgetPreview.js's four ambient render functions prefer opts.loopPreviewData over a fresh fetchCurrentPostData() call whenever it's present. Net effect: exactly one AJAX request per Loop container regardless of item count, with zero follow-up requests for its ambient widgets — a page with 50 Loop containers stays around 50 requests total, not 500+.

Out of scope: post_content and template_reference inside a Loop item aren't JS-rendered (see Canvas Preview Rendering), so each clone still calls PHP separately per instance regardless of the batching above — correctness is fine (each clone resolves its own real post id, and a Dynamic Template reference inside a Loop item correctly resolves its own ambient widgets to that iteration's post — see Dynamic Template Widget above), it just isn't de-duplicated the way the batched ambient-widget data is.


Off-Canvas — drawer/modal container

Files: admin/assets/js/src/page-builder/config/offCanvasPickerEntry.jscreateOffCanvasDefaultOptions() admin/assets/js/src/page-builder/config/widgets/offcanvas.styles.json admin/assets/js/src/page-builder/config/widgets/structuralDialogTabs.jsgetDivStyleConfig(), Off-Canvas Content-tab fields admin/assets/js/src/page-builder/components/canvas/CanvasDiv.jsxisOffCanvas render branch admin/assets/js/src/shared/utils/variableTokens.jsisOffCanvasActionToken()/makeOffCanvasActionToken() admin/assets/js/src/shared/components/VariablePicker.jsx — "Off-Canvas Actions" picker section includes/class-sanilwb-frontend-renderer.phprender_offcanvas_div(), the routing check in render_div_or_loop_clones() includes/helpers/class-sanilwb-offcanvas-trigger-helper.php public/js/sanilwb-offcanvas.js public/css/_offcanvas.scss

A div permanently flagged sanilwb_offcanvas_enabled: '1' at creation (no toggle converts a div to/from this afterward) renders as a hidden-by-default panel — a cart drawer, mobile menu, or centered modal — openable from a click anywhere else on the page or Site Layout. It's excluded from template context and from nesting inside a Loop or Carousel, both enforced client-side (Add-menu hiding) and server-side (SANILWB_Ajax::schema_has_content_not_allowed_in_context()), since either would produce more than one drawer sharing one identity.

Identity: sanilwb_offcanvas_uid, not the node's own id. A div's top-level id is never persisted (treeTraversal.js's createDiv()/serialization.js regenerate it fresh on every load), so a trigger token built from it would go stale the instant the page reloads. sanilwb_offcanvas_uid is an ordinary, real options field instead — generated once at creation (createOffCanvasDefaultOptions()), regenerated on duplicate (actions.js's cloneWithFreshIds()) so two drawers can never collide.

Content tab: Enable Editing Mode (editor-only, never read by PHP — off by default so the canvas matches the real site's hidden starting state; on renders the drawer open with editable children), Close on Overlay Click, Close on Escape Key, Prevent Page Scroll While Open — each a plain toggle read directly by the frontend runtime script as a data-* attribute.

Style tab (offcanvas.styles.json): a reduced field set compared to a plain Container — a simplified Layout/Display section, Size, Background, Gradient, Border, Box Shadow, Transitions, Animations, and two Off-Canvas-only sections: Position (sanilwb_offcanvas_position — Top/Top Right/Bottom/Top Left/Center/Custom, using the generic presets mechanism — see Style Field JSON Reference — to override the Offset input-group's four fields without ever writing over what a Custom drawer has manually set) and Overlay (sanilwb_offcanvas_overlay_color, scope: "external", targeting .sanilwb-offcanvas-overlay). Overlay Color is the one field here whose PHP CSS isn't generic: the generic collect_widget_target_css() only ever compiles a target as a CSS descendant of the widget's own class, but the overlay is the panel's ancestor — the reverse relationship — so render_offcanvas_div() excludes that target from the generic loop and emits the one declaration by hand. The JS side has no such exception; CanvasDiv.jsx reads it via the ordinary elementStyles.get('.sanilwb-offcanvas-overlay'), ordinary buildElementStyles() output.

Rendering shape — two elements, always, root or not. CanvasDiv.jsx (editor) and render_offcanvas_div() (PHP) both render an always-present, always-full-screen overlay (position: fixed; inset: 0) wrapping the sized/positioned panel — the panel is a normal child of the overlay, so with no Offset set at all it naturally sits at the overlay's own top-left. margin: auto is unconditional on the panel (not a field): a no-op unless all four Offset sides are 0, in which case it centers the panel inside the overlay (the Center preset relies on this). A z-index: 9999 on the overlay is likewise unconditional — every div in this codebase defaults to position: relative, so without an explicit z-index a later same-stacking-context sibling would paint over an earlier position: fixed drawer purely by DOM order. Off-Canvas skips root-wrapper chrome (render_root_wrapper_open()) entirely, even when placed at the page root — a fixed drawer is never "a page section."

Editor canvas gotcha (fixed 2026-08-10): the overlay's inline display must always resolve to a real value ('none' or 'block'), never undefined. The real frontend's own base CSS (_offcanvas.scss) also loads inside the Page Builder's canvas iframe (it calls wp_head()/wp_footer() like any real page), and that stylesheet's .sanilwb-offcanvas-overlay { display: none; } rule wins over an absent inline display — React omits the attribute entirely when the ternary's "show" branch was undefined, leaving nothing to override it.

PHP rendering is deferred to wp_footer, not inline. render_div_or_loop_clones() intercepts any sanilwb_offcanvas_enabled node before its normal position in the tree, buffers render_offcanvas_div()'s output into $this->pending_offcanvas_html, and a wp_footer callback (registered once per request, default priority 10, same pattern as the Carousel asset enqueue) echoes every buffered drawer immediately before </body> — regardless of how deeply nested in the tree the div actually was. Its own CSS still resolves against 'offcanvas' (not 'container'), so it reads offcanvas.styles.json via the same SANILWB_Style_Field_Targets registry every other type uses.

Trigger tokens{{sanilwb_offcanvas:<uid>:<open|close|toggle>}}, format defined in variableTokens.js, not a SANILWB_Dynamic_Data_Source_Interface implementation (those resolve to plain text substituted into a string; this resolves to a set of markup attributes instead — a different shape). A URL-type field opts into showing the "Off-Canvas Actions" section of the Variable Picker via a field-level offCanvasTrigger: true property in its own widget config (Button, Icon, Heading, Image today — a genuine per-widget opt-in, not a hack, since it's a plain field property read generically by TextField.jsx/VariablePicker.jsx, never a widget-type name hardcoded into either). SANILWB_Offcanvas_Trigger_Helper::maybe_build_trigger_attrs() (PHP) turns a matching token into data-sanilwb-offcanvas-trigger/data-sanilwb-offcanvas-action plus href="#" — never href="javascript:..." or an inline onclick=, both of which sanitize_ajax_preview_html() strips from the editor's AJAX preview path. A token pointing at a deleted drawer resolves to no attributes at all (inert href="#") rather than an error.

Frontend runtime (public/js/sanilwb-offcanvas.js, vanilla JS) — one delegated click listener catches every trigger regardless of how many exist or when they were added; openDrawer()/closeDrawer()/toggleDrawer() add/remove .is-open on the overlay (base visibility is pure CSS: _offcanvas.scss's display: none; &.is-open { display: block; }). A scrollLockCount (not a boolean) tracks how many currently-open drawers asked for the page-scroll lock, so closing one drawer never releases the lock while another still needs it. Overlay-click-to-close only fires when the click landed directly on the overlay element itself (event.target, not .closest()) — bubbling from the panel or anything inside it never closes it, matching the usual modal convention.

Closing is deferred when an "On Hide" animation is configured (see Animations below) — closeDrawer() adds a sanilwb-offcanvas-overlay--closing modifier class alongside .is-open (not instead of it, since the overlay still needs display: block for anything inside it to be visible while it plays), then finalizes the real close (removing both classes, decrementing the scroll lock) either on that animation's animationend or a 1000ms fallback timer, whichever comes first. Detecting whether a real "On Hide" animation exists at all — most drawers have none — compares each element's animationName + '|' + animationDirection signature immediately before and after the --closing class is added; comparing animationName alone is not enough, since reusing the exact same entrance keyframe for the exit (see below) means the name never changes, only the direction does.

Animations — "On Show" / "On Hide" triggers. Every preset in SANILWB_CSS_Compiler::get_animation_preset_catalog() is entrance-shaped (starts off-screen/invisible, ends at rest, held there by animation-fill-mode: forwards) — there is no separate exit keyframe for any of them. "On Show" plays the configured animation, scoped generically to .sanilwb-offcanvas-overlay.is-open <selector> (build_animation_rules()/buildAnimationRules()'s 'show' branch) — this is a plain CSS descendant selector, so it correctly matches the drawer panel's own wrapper or any widget nested further inside it, and simply never matches (a quiet no-op, same as "On Loop" outside a Loop) for anything that never ends up inside a drawer. "On Hide" reuses the exact same keyframe with animation-direction: reverse added ('hide' branch, scoped to .sanilwb-offcanvas-overlay.is-open.sanilwb-offcanvas-overlay--closing <selector>), so the element retraces its own entrance path outward — pick the same Type/Direction on both rows to get a mirrored open/close, not the visually-opposite direction.

Browsers only restart a CSS animation when its animation-name actually changes — reusing the same keyframe for the reverse pass means, without help, the browser treats "On Hide" as a continuation of the (already finished) entrance animation and plays nothing. sanilwb-offcanvas.js works around this the standard way: for every element whose animation signature changed when --closing was added, it sets element.style.animation = 'none', forces one shared reflow (overlay.offsetWidth), then clears the inline override — going from none back to the real value is a genuine change, which is something browsers restart for.

Known limitation, by design ("basic," not exact): the animationend listener finalizes the close on the first such event to bubble up from the drawer, not the slowest. A drawer with more than one differently-timed "On Hide" animation on different descendants closes on whichever finishes first, cutting the others short. Keep multiple On Hide animations in one drawer close in duration if this matters.

Not built (explicitly deferred): clicking a trigger inside the Page Builder canvas does not open/close the target drawer in the editor for live testing — Enable Editing Mode is the only way to see a drawer's contents while editing. Building this cleanly is architecturally straightforward (the same offCanvasTrigger-flagged-field lookup already used by the picker, plus a new ephemeral per-drawer open/closed store slice, entirely separate from the persisted Enable Editing Mode flag) but has not been requested.


Adding a New Widget Type — Checklist

  1. admin/assets/js/src/page-builder/config/widgets/your-type/your-type.js (new file, in its own subfolder) — Export definition (type, label, icon, barBg, barText, defaultValues) and buildTabs(...). Then register it in config/widgets/index.js: import * as yourType from './your-type/your-type', add yourType.definition to WIDGET_TYPES, and add a case 'your-type': in getWidgetDialogTabs() calling yourType.buildTabs(...).

  2. usePageBuilderStore.js — No changes required. The store is widget-type-agnostic; addWidget(parentId, type, insertAt?) works for any type in WIDGET_TYPES.

  3. A new file in includes/shortcodes/ — Create a SANILWB_YourType_Shortcode::render() class to render the widget's frontend output from the sanilwb_* attribute values, then add one elseif branch for it in SANILWB_Shortcode_Handler::process_shortcodes() (includes/class-sanilwb-shortcode-handler.php). See the Adding a New Widget Type tutorial for a full walkthrough.

  4. Test: open the Widget Picker (from a container's + button in the canvas, or its Add Widget action in the Layers panel), add the new widget, open its settings dialog, verify all fields save and render correctly in the canvas preview and on the frontend.