Page Builder — Developer Reference

The Page Builder is a full-screen React + Zustand layout editor that mounts inside a WordPress meta box. It lets editors compose page content using a hierarchy of sections, rows, columns, and widgets. Everything is serialized to JSON and saved in the sanilwb_data post meta key. On the frontend, PHP renders the stored data as Bootstrap layout with [sanilwb] shortcodes.


Architecture Overview

PageBuilder.jsx                ← root component, keyboard shortcuts, close handler
├── BuilderTopbar              ← shared topbar shell (left/right slots)
│   └── SaveButton             ← save state machine, AJAX POST, disabled when clean
├── 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
└── PageBuilderSettingsSidebar ← right-side settings panel (section / column / widget)

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.


Entry Point and Data Bootstrap

File Purpose
admin/assets/js/src/page-builder/index.jsx React app entry — mounts <PageBuilder />
admin/templates/meta-box-contents.php PHP template that renders the meta box and injects window.PageBuilderData
admin/class-sanil-website-builder-admin.php meta_box_html() — loads post meta, builds PageBuilderData, enqueues dist/page-builder.js

window.PageBuilderData shape

PHP injects this object before the JS bundle runs:

{
  builderData:        string,  // saved sanilwb_data post meta (JSON string or '[]')
  postId:             number,
  postTitle:          string,
  postEditUrl:        string,
  pbSaveNonce:        string,  // nonce for sanilwb_pb_save AJAX action
  pluginUrl:          string,
  categories:         [{ id, name }],
  availableTemplates: [{ id, name, builder_type, category, preview, supports, buttons }],
}

On mount, initFromWindowData() reads builderData, parses it from PHP format, and seeds the store's sections array. Everything else in PageBuilderData is read lazily (e.g. categories and availableTemplates are read when a widget dialog opens).


Data Structure

The Page Builder stores layout as a flat array of sections. Each section holds rows, each row holds columns, and each top-level column holds an ordered items[] array that can contain any mix of widgets and nested rows.

sections[]
  └── section
        ├── id
        ├── options           { sanilwb_admin_label, sanilwb_bck_color, ... }
        └── rows[]
              └── row
                    ├── id
                    ├── layoutId  (e.g. 'six_six-column-template')
                    ├── options   { sanilwb_admin_label }
                    └── columns[]
                          └── column
                                ├── id
                                ├── options
                                └── items[]         ← ordered mix of widgets and nested rows
                                      ├── { kind: 'widget', id, uid, type, values }
                                      └── { kind: 'nestedRow', id, layoutId, columns[] }
                                              └── nestedColumn
                                                    ├── id
                                                    └── widgets[]   ← nested columns use widgets[], not items[]

Key rules: - A top-level column's items[] can hold any combination of widgets and nested rows in any order. There is no either/or constraint. - A column can contain multiple nested rows. - Nested columns (inside nested rows) still use a flat widgets[] array — the items[] model applies only to top-level columns. - Nested rows are limited to one level deep — you cannot add a nested row inside a nested column. - 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() converts the internal tree to a PHP-compatible JSON array saved in sanilwb_data. The shape matches what the PHP shortcode renderer expects:

[{
  "section_options": { "sanilwb_admin_label": "Section 1", "sanilwb_bck_color": "" },
  "rows": [{
    "row_template": "six_six-column-template",
    "row_options":  { "sanilwb_admin_label": "Row 1" },
    "columns": [
      {
        "column_options": {},
        "items": [
          {
            "kind":          "widget",
            "widget_uid":    "abc123",
            "widget_type":   "posts",
            "widget_values": "{\"sanilwb_style\":\"db:3\",\"sanilwb_columns\":\"3\"}",
            "widget_title":  "Posts 1",
            "show_desktop":  "1",
            "show_tablet":   "1",
            "show_mobile":   "1"
          },
          {
            "kind":                "nested_row",
            "id":                  "nr-abc",
            "nested_row_template": "six_six-column-template",
            "columns": [
              { "widgets": [] },
              { "widgets": [] }
            ]
          }
        ]
      }
    ]
  }]
}]

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). Each item carries a kind discriminator ("widget" or "nested_row") so the PHP renderer can process them in order without separate fields.


Store — usePageBuilderStore

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

This is the single source of truth for all layout state. Every action that mutates state calls snapshot(label) first to push the current state onto the undo stack.

State

Key Type Purpose
sections Section[] The live layout tree
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 'section', 'column', or 'widget'.
settingsPanelIsNew boolean True when the panel was opened for a brand-new uncommitted widget — on close without save, the widget is removed with no undo snapshot
selectedWidgetId string\|null Widget highlighted by a canvas click — layers panel scrolls and highlights the matching bar
selectedLayerId string\|null Layer item most recently clicked in the layers panel — IframeCanvas subscribes to scroll the matching canvas element into view
collapsedLayerIds { [id]: true } Opt-out collapse map for all collapsible layer bars (section, row, column, nested row, nested column). 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.

Debounced edit snapshots: field-level edits (updateSectionOptions, updateColumnOptions, updateRowOptions, updateNestedRowOptions, updateWidget) call scheduleDebouncedSnapshot(get, set, 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 all of these 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 (including two items of the same type, e.g. Section A → Section B) flushes the in-progress burst as its own entry instead of merging it with the new one.

The label itself is no longer a fixed string — it 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 is restored to whatever it was before the burst started
Only sanilwb_admin_label changed Renamed to {new name}
Exactly one other field changed Edit {item name} {field label} (e.g. "Edit Post 3 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(), SECTION_DIALOG_TABS, COLUMN_DIALOG_TABS, ROW_DIALOG_TABS, NESTED_ROW_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 (e.g. a per-template title bar button URL, generated dynamically per template) fall back to a humanized version of the raw field key.

Sections

Action Description
addSection(layoutId?) Appends a new section with one initial row
addSectionAfter(sectionId, layoutId?) Inserts a section immediately after the given section
removeSection(sectionId) Removes a section and all its content
moveSection(fromIndex, toIndex) Reorders sections by index
updateSectionOptions(sectionId, options) Replaces the section's options object (saves background color, admin label, etc.)

Rows

Action Description
addRow(sectionId, layoutId?) Appends a row to the end of a section
addRowAfter(sectionId, afterRowId, layoutId?) Inserts a row immediately after the given row
removeRow(sectionId, rowId) Removes a row and all its content
moveRow(sectionId, fromIndex, toIndex) Reorders rows within the same section
moveRowToSection(rowId, fromSectionId, toSectionId, toIndex) Moves a row from one section to another
changeRowLayout(sectionId, rowId, newLayoutId) Changes column split — preserves existing column data for columns that survive the change
updateRowOptions(rowId, options) Replaces a row's options object (saves admin label, etc.). Searches all sections to find the row.

Columns

Action Description
moveColumn(sectionId, rowId, fromIndex, toIndex) Reorders columns within the same row
updateColumnOptions(columnId, options) Replaces a column's options object. Works for both top-level and nested columns via mapAllColumns.

Nested Rows

Action Description
addNestedRow(sectionId, rowId, columnId, layoutId?) Appends a nested row item to the column's items[]. Multiple nested rows per column are allowed.
removeNestedRow(sectionId, rowId, columnId, nestedRowId) Removes the specific nested row identified by nestedRowId and all its content
changeNestedRowLayout(sectionId, rowId, columnId, nestedRowId, newLayoutId) Changes the column split of the specific nested row identified by nestedRowId
updateNestedRowOptions(nestedRowId, options) Replaces a nested row's options object. Traverses the full sections → rows → columns → items tree to find it.

Widgets

Action Description
addWidget(columnId, type) Creates a new widget of the given type. For top-level columns, appends { kind: 'widget', ...widget } to items[]. For nested columns, appends the bare widget to widgets[]. Returns the widget object.
removeWidget(widgetId) Removes a widget (creates undo snapshot)
removeNewWidget(widgetId) Removes a brand-new widget the user cancelled — no undo snapshot
moveItem(columnId, fromIndex, toIndex) Reorders any item (widget or nested row) within a top-level column's items[]
moveWidget(columnId, fromIndex, toIndex) Reorders widgets within a nested column's widgets[]
moveWidgetAcrossColumns(fromColumnId, fromIndex, toColumnId, toIndex) Moves a widget between any two columns (works for top-level and nested columns)
updateWidget(widgetId, values) Replaces a widget's values object after the settings panel saves
duplicateWidget(widgetId) Inserts a deep copy of the widget immediately after the original

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(widgetId) Walks the full sections tree to find the widget, then calls expandLayer on every ancestor (section → row → column → nestedRow → nestedColumn as applicable). Called by CanvasWidgets on iframe click so collapsed parents are automatically opened.

Settings panel

Action Description
openSettingsPanel(type, id, isNew?) Opens the settings sidebar for the given item. If isDirty is true and a different item is already open, shows a window.confirm before switching. isNew defaults to false; pass true for a freshly created 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

Internal helpers (not exported)

  • mapAllColumns(sections, topFn, nestedFn) — applies separate mapping functions to every column in the tree. topFn receives top-level columns (which have items[]); nestedFn receives nested columns (which have widgets[]). Used by widget add/remove/update/duplicate actions.
  • countWidgetsByType(sections, type) — counts widgets of a given type across the full tree (both items[] and nested widgets[]). Used to generate sequential admin labels like "Posts 1", "Posts 2".
  • fromPhpFormat(phpData) — deserializes the stored JSON array back into the internal tree shape, reading col.items[] and mapping each item by its kind discriminator.
  • toPhpFormat(sections) — serializes the internal tree to the PHP-compatible JSON array, writing col.items[] with kind: 'widget' or kind: 'nested_row' on each item.
  • extractItemFromSections(sections, columnId, itemIndex) — mutates a cloned tree to remove a widget from a given column position. Used by moveWidgetAcrossColumns.
  • insertItemIntoSections(sections, columnId, insertIndex, widgetItem) — mutates a cloned tree to insert a widget at a given column position. Handles both top-level (items[]) and nested (widgets[]) columns, adding or stripping the kind discriminator as needed.

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: layers toggle + post title. Right slot: device toggles, SaveButton, Close button.
  • Body — LayersPanel and HistoryPanel on the left, PreviewPanel fills the centre, PageBuilderSettingsSidebar sits on the right.
  • PageBuilderSettingsSidebar — reads activeSettingsTarget from the store and renders the appropriate form fields (section, column, or widget) inside the shared SettingsSidebar shell. Opening any layer bar row calls openSettingsPanel(type, id) in the store, which this component subscribes to.

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

SaveButton.jsx

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

Owns the entire save flow:

  • Reads serializeToJson, markSaved, and hasUnsavedChanges from usePageBuilderStore directly — no props needed.
  • Disabled when hasUnsavedChanges is false or a save is already in flight (saveState !== 'idle'). This prevents multiple AJAX requests from firing when there is nothing to save or a request is already pending.
  • State machine: idlesavingsaved (1.5s) → idle. A synchronous isSavingRef mutex provides a secondary guard against programmatic double-calls.
  • POSTs to window.ajaxurl with action sanilwb_pb_save.

Keyboard shortcuts: - Ctrl+Z — undo - Ctrl+Y / Ctrl+Shift+Z — 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 is the single source of truth for the active device (desktop | tablet | mobile). The top bar buttons call setDevice(). PreviewPanel reads the device and passes it to IframeCanvas, which resizes the iframe width. The widget settings dialog's device toggle also reads useDeviceStore, so switching the top-bar device and the dialog device are in sync.


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.

Empty state: when sections.length === 0, shows a placeholder with guidance text.

Layer bar color coding:

Layer type CSS class Color
Section sanilwb-layer-item--section Blue
Row sanilwb-layer-item--row Green
Column sanilwb-layer-item--column Violet
Nested Row sanilwb-layer-item--nested-row Light green
Nested Column sanilwb-layer-item--nested-column Light violet
Widget sanilwb-layer-item--widget Amber + colored dot matching widget type

Widget selection sync: clicking a widget bar sets selectedWidgetId in the store. The canvas also calls setSelectedWidgetId when the user clicks a widget in the preview. The bar uses a useEffect + scrollIntoView to scroll itself into view when it becomes selected.

Auto-expand on iframe click: when a widget is clicked in the preview iframe, CanvasWidgets also calls expandAncestorsOf(widgetId) in the store. This walks the sections tree and removes every ancestor ID (section, row, column, nested row, nested column) from collapsedLayerIds, so collapsed parents are opened before the widget bar scrolls into view.

Collapse state: each collapsible layer bar (SectionLayerBar, RowLayerBar, ColumnLayerBar, NestedRowLayerBar, NestedColumnLayerBar) derives its open/closed state from collapsedLayerIds in the store via a per-ID boolean selector (!s.collapsedLayerIds[id]). The toggle buttons call 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. All item types (section, row, column, widget, nested row) are useSortable nodes. The handleDragEnd function routes based on active.data.current.type:

  • section — reorders by index in sections[]
  • row — reorders within the same section or moves to another section, resolving the destination from the over target's type (section, row, or column)
  • column — reorders within the same row only
  • widget — reorders within the same column (calls moveItem for top-level columns, moveWidget for nested) or moves to another column (calls moveWidgetAcrossColumns)
  • nestedRow — reorders within the same column's items[] only; cross-column moves are rejected with a feedback message

A DragOverlay renders a lightweight clone 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.

Actions per layer type:

SectionLayerBar — clicking the row opens the section settings sidebar; add row, duplicate section, delete section

RowLayerBar — change layout (opens LayoutPickerPopover), add row below, delete row

ColumnLayerBar — clicking the row opens the column settings sidebar; add widget (opens WidgetPickerDialog), add nested row. Both buttons are always visible — columns can freely mix widgets and nested rows in any order.

NestedRowLayerBar — change layout, delete nested row. Each nested row bar is independently sortable within its parent column's items[] alongside widgets.

NestedColumnLayerBar — add widget

WidgetLayerBar — clicking the row opens the widget settings sidebar; 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. Template Builder's template-builder/components/HistoryPanel.jsx is the same kind of thin wrapper around the same shared component — the timeline rendering logic (flat list, current-entry highlight, jump-index math, empty state) exists in exactly one place instead of being duplicated per builder.

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 both admin-styles.scss (Page Builder) and template-builder.scss (Template Builder) — not the sanilwb-pb-layers* classes that LayersPanel.jsx uses for its own panel shell.


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, Bootstrap, 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 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, so carousels re-initialise after any edit.

Device widths:

Device iframe width
desktop 100% (min-width: 992px to keep Bootstrap col-lg-* columns side-by-side)
tablet BREAKPOINTS.CANVAS_TABLET
mobile BREAKPOINTS.CANVAS_MOBILE

Width transitions are animated (transition: width 0.25s ease). On tablet and mobile the iframe is horizontally centred with margin: auto.


Widget System

Widget type registry

File: admin/assets/js/src/page-builder/config/widgetTypes.js

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

Property Type Purpose
type string Key (e.g. 'posts')
label string Human-readable name shown in the widget picker and layer bars
icon string pb-icon-* icon name for the widget picker
barBg string CSS color for the colored dot on the widget layer bar
barText string CSS text color (paired with barBg)
defaultValues object Initial sanilwb_* values applied when a new widget is created

Dialog tabs are not stored in WIDGET_TYPES — they are built by getWidgetDialogTabs(type, availableTemplates, categories) 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
posts Posts Posts listing grid with query controls, display toggles, and optional carousel
read_more_btn Read More Button CTA button with URL, style, color, size, width, and alignment controls
title_bar Title Bar Title bar using a titlebar template, with custom title text and per-button URL fields
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
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 existing sanilwb_get_featured_image() helper (same helper the Posts widget uses), 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

"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 the planned Site Layout CPT will rely on for archive-type templates.

Note on Paragraph: the shortcode attribute pipeline runs every attribute through sanitize_text_field() in process_shortcodes() before any handler 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 (renamed first section from 'Container' to 'Spacing' for readability).
  2. Appends SHARED_ADVANCED_TAB at the very end.

This means every widget dialog ends with a consistent Style and Advanced tab structure.

Posts widget — supports filtering

When a user selects a template in the Posts widget's Template tab, the Content tab's display toggle fields (Show Image, Show Topic, Show Excerpt, etc.) show or hide based on whether that template actually contains those node types. This is implemented via supports_* flags (supports_image, supports_topic, etc.) derived from the selected template's supports property (a comma-separated string). These flags are passed as extraValues to the Dialog component — they are used in showIf evaluation but never saved to the widget's stored values.


Settings Sidebar

PageBuilderSettingsSidebar.jsx

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

The settings panel for sections, columns, 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 findWidgetById / findSectionById / findColumnById.
  • Computes a breadcrumb trail (findBreadcrumbPath) showing the path to the current item (e.g. Page › Section A › Row 1 when a column is open).
  • 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 and the target is a widget, calls removeWidget(id) before closing so a cancelled new widget is not left in the tree.
  • handleSave — calls updateWidget / updateSectionOptions / updateColumnOptions 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 Mounts / unmounts the panel
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.

findBreadcrumbPath.js

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

Returns an array of ancestor label strings for a given type + id pair:

Target Example result
section ['Page']
column (top-level) ['Page', 'Section A', 'Row 1']
column (nested) ['Page', 'Section A', 'Row 1', 'Column']
widget (top-level column) ['Page', 'Section A', 'Row 1', 'Column']
widget (nested column) ['Page', 'Section A', 'Row 1', 'Column', 'Nested']

Section and row labels come from options.sanilwb_admin_label; fall back to 'Section' / 'Row' 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, section 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. The Posts widget uses this to keep the Content tab disabled until a template is selected.

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.


Shared Dialog Fields

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

These field definitions are shared across all builders (Page Builder and Template Builder).

SHARED_STYLE_FIELDS

Injected into the Style tab of every widget and section dialog. Contains:

  • Container section — Margin (top/bottom, side-by-side), Padding (vertical/horizontal, side-by-side). All responsive.
  • Borders section — Border Radius (top/bottom), Border Width (all four sides in a 2×2 grid), Border Style (select), Border Color. All responsive.

Field names (sanilwb_margin_top, sanilwb_padding_vertical, border-radius-top, border-width-top, border-style, border-color) must not be renamed — they are read by both the PHP template compiler and the JS canvas style builder.

SHARED_ADVANCED_TAB

The Advanced tab appended to every widget and section dialog. 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)

The sanilwb_admin_label value is what appears as the layer name in the Layers panel.

Typography Fields

File: admin/assets/js/src/page-builder/config/widgetTypes.jsTYPOGRAPHY_STYLE_FIELDS

Shared field set spread into the Style tab of every text-bearing widget (Heading, Paragraph, Post Title, Archive Title, Excerpt): Text Color, Font Size, Font (family), Font Weight, Font Style, Line Height, Text Align. It mirrors the field set Template Builder's buildFontFields() gives its own text nodes, reusing the same font-family / font-weight / font-style field components — but with flat sanilwb_* names, since each Page Builder widget owns its own isolated field namespace (Template Builder needs a per-node-role prefix like heading-font-family because multiple roles coexist in one compiled template).

Post Content does not use this field set. It has no single element to style — see Post Content Widget Styling below for its own, structurally different per-selector system.

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 class-sanil-website-builder-public.php — the same shape collect_element_css() 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 posts_shortcode()'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 — only Heading and Post Title get a second color field, sanilwb_hover_color, since they're the only two typography widgets that can wrap their text in a link (sanilwb_link_url / sanilwb_link). It's added via TYPOGRAPHY_STYLE_FIELDS_WITH_LINK_HOVER in widgetTypes.js, which tags the base Text Color field hoverState: 'normal' and the new field hoverState: 'hover' — this reuses the shared Normal/Hover section toggle already built for Template Builder (FormFields.jsx), no new UI needed. build_typography_responsive_style() emits it as [data-uid="..."] a:hover{color:...!important} at all three breakpoints, resolved from hover_color/hover_color__tablet/hover_color__mobile.

Link resting-state color also needs its own rule. The Text Color declaration lands on the parent tag (<h2>, etc.); a linked <a> inside it only inherits that color, and the browser's own default link color beats a merely-inherited value — so without an explicit rule, linked text would show as the browser's default blue outside of hover. build_typography_responsive_style() also emits [data-uid="..."] a{color:...!important} (again at all three breakpoints) whenever the widget has a link. This mirrors Template Builder's own heading-color/heading-hover-color handling in class-sanilwb-template-compiler.php, which has the identical fix for the identical reason.

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 (SANILWB_Admin_PageBuilder::save_metabox() and ajax_pb_save()), sync_page_builder_fonts() recursively scans the saved sanilwb_data for any sanilwb_font_family values and registers them with SANILWB_Font_Sync::update_entity_fonts('post_' . $post_id, $fonts) — the same 'post_{ID}' entity-id convention SANILWB_Template_Compiler::extract_and_sync_fonts() already uses for Template Builder schemas. That call fires SANILWB_Font_Downloader::process_fonts(), which downloads and enqueues the font site-wide. Until the widget is saved at least once, a newly picked font won't actually render.

Post Content Widget Styling

Files: admin/assets/js/src/page-builder/config/widgetTypes.js (buildPostContentTabs(), buildTypographyFieldsWithPrefix(), buildSpacingFieldsWithPrefix()) · admin/assets/js/src/page-builder/utils/patchWidgetLive.js (buildPostContentStyleCss()) · public/class-sanil-website-builder-public.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 (reusing BOX_SHADOW_OPTIONS) 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.

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 (enqueue_scripts() for the classic metabox, enqueue_pb_editor_assets() for the full-screen editor) call wp_enqueue_media() — without it, wp.media is undefined and the picker button silently does nothing.


Column Layout Definitions

File: admin/assets/js/src/page-builder/config/columnLayouts.js

COLUMN_LAYOUTS defines all available column splits. Each layout has:

Property Purpose
id The string stored in row_template / nested_row_template in the JSON. Must match PHP's Bootstrap class lookup.
label Long description shown in the layout picker
shortLabel Short badge shown on row layer bars (e.g. 1 : 1)
columns Array of Bootstrap grid widths (sum to 12)
group Visual grouping in the layout picker popover

Available layouts:

ID Split Short label
single-column-template 12 Full
six_six-column-template 6 / 6 1 : 1
eight_four-column-template 8 / 4 2 : 1
nine_three-column-template 9 / 3 3 : 1
three_nine-column-template 3 / 9 1 : 3
four_eight-column-template 4 / 8 1 : 2
five_seven-column-template 5 / 7 5 : 7
seven_five-column-template 7 / 5 7 : 5

The default layout for new sections and rows is single-column-template. When a row layout changes, existing column data is preserved for columns that survive the change — new columns are created only if the new layout has more columns.


Canvas Preview Rendering

Files: 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

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

  • JS-renderedheading, paragraph, image, read_more_btn, post_title, archive_title, featured_image, excerpt. 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). No sanilwb_pb_render_widget AJAX call happens for these types on a settings change.
  • AJAX-renderedposts, title_bar, post_content, each for its own reason: posts/title_bar depend on a compiled db:{id} template or a live WP_Query; post_content depends on the real the_content filter chain (shortcodes, blocks, embeds), which can't be reimplemented in JS. renderWidgetPreview() returns null for all three 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 posts/title_bar/post_content 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 posts/title_bar/post_content — it distinguishes style-only edits (patched live via CSS, no AJAX), title_bar content edits (patched live via DOM text/attribute updates), and Post Content edits (also patched live via CSS — see Post Content Widget Styling) from edits that require a full AJAX re-render. It returns 'unsupported' for every widget type that doesn't define structuralFields in WIDGET_TYPES at all (i.e. every JS-rendered type) — that return value is what used to fall through to the generic AJAX spinner path before this fix. Post Content is the one type whose structuralFields is an empty array rather than a populated one or missing entirely — every one of its fields is style-only, so it can only ever classify as 'none' or 'style', never 'structural', once past the unavoidable first-mount fetch.

Re-render scope: updateWidget() in the store rebuilds only the path from the root down to the edited widget (mapAllColumns() returns the same section/row/column object references for anything that doesn't contain the widget). Combined with React.memo on CanvasSection, CanvasRow, CanvasColumn, CanvasNestedRow, CanvasNestedColumn, and CanvasWidget, only the edited widget actually re-renders — sibling widgets, columns, rows, and sections bail out of memo without re-executing.


Save / AJAX

AJAX action: sanilwb_pb_save
Nonce: window.PageBuilderData.pbSaveNonce
Handler: admin/class-sanil-website-builder-admin.php

Save logic lives entirely in SaveButton.jsx. The button is disabled (greyed out, not-allowed cursor) when there are no unsaved changes or a save is already in flight — no AJAX request fires in those states. When clicked while enabled, it serializes the store via serializeToJson() and POSTs to window.ajaxurl. The button shows three states: idlesaving…Saved (1.5s) → idle.

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
public/helpers.php

PHP reads the stored sanilwb_data post meta, iterates the sections array, renders Bootstrap rows and columns, and calls do_shortcode('[sanilwb type="posts" ...]') for each widget. The widget_values JSON string is decoded and each key-value pair becomes a shortcode attribute.

The buildShortcode function in widgetTypes.js strips the sanilwb_ prefix from field names when building the shortcode string — the PHP shortcode handler receives attribute names without that prefix.

Ambient post context in the AJAX canvas preview

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.

Unified style block

All spacing, border, and layout styles (margin, padding, border-radius, border-width, border-style, border-color, flex layout) are emitted as a single <style id="sanilwb-page-styles"> block that appears immediately before the page builder HTML. This block is generated fresh on every page load by render_page_builder_content().

How it works:

  1. ob_start() buffers the HTML while the section/row/column/widget loop runs.
  2. Every element gets a unique scoped CSS class (sanilwb-el-N) assigned by a static page-request counter. Widgets get a persistent class (sanilwb-widget-{uid}) derived from the stable widget_uid field.
  3. Three private helpers collect the CSS rules:
  4. build_appearance_declarations($opts, $suffix) — background-color, margin, padding, border-radius, border-width, border-style, border-color
  5. build_layout_declarations($opts, $suffix) — display, flex-direction, align-items, justify-content, flex-wrap, align-content, gap
  6. collect_element_css($selector, $opts, $include_layout) — calls both helpers for the desktop pass (no suffix), tablet pass (__tablet), and mobile pass (__mobile), wrapping breakpoint rules in media queries and appending everything to $this->pending_page_css
  7. After the loop, ob_get_clean() captures the HTML. The style block is echoed first, then the HTML.

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 the helpers. If a responsive value is not set, no rule is emitted for that breakpoint.

No inline styles: structural elements (sections, rows, columns, nested rows, nested columns, widgets) carry only their scoped class — no style="" attribute. All visual rules live exclusively in the unified <style> block.

Why <style> in body: wp_add_inline_style() silently drops styles added after wp_head() has fired (which it has by the time the_content runs). The body-level <style> tag is the intentional workaround.


Adding a New Widget Type — Checklist

  1. widgetTypes.js — Add an entry to WIDGET_TYPES with type, label, icon, barBg, barText, and defaultValues. Add a case in getWidgetDialogTabs() that calls a new buildYourTypeTabs() function.

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

  3. public/class-sanil-website-builder-public.php — Add a shortcode handler (or a case in the existing switch) to render the widget's frontend output from the sanilwb_* attribute values.

  4. Test: open the Widget Picker 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.