Claude Skill

wordpress-elementor-dev

Expert WordPress and Elementor Pro development skill. Use this whenever the user asks about WordPress theme development, Elementor Pro customization, plugin architecture, custom post types, hooks/filters, Loop Grids, Dynamic Tags, Theme Builder templates, off-canvas UIs, custom C

LLM Mart · 0 points · 2 views 5 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download guramzhgamadze-wordpress-elementor-skill-wp-elementor-skill-3ad244d.zip · 229 KB

Install

skills CLI npx skills add https://github.com/guramzhgamadze/WordPress-Elementor-Skill/tree/main/wp-elementor-skill
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install guramzhgamadze-wordpress-elementor-skill@llmmart
Git git clone https://github.com/guramzhgamadze/WordPress-Elementor-Skill.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole guramzhgamadze/wordpress-elementor-skill collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

WordPress & Elementor Pro — Skill Router

A complete procedural guide for producing production-grade WordPress and Elementor Pro code. This file is the router — read it first, then load the relevant sub-file(s) for the task at hand.


Sub-file Map — Read the Right File for Each Task

Task type Read this file
Plugin scaffold, child theme, code placement, CPT, AJAX handler scaffolding.md
PHP standards, sanitization, escaping, nonces, WP_Error, transients, password hashing php-standards.md
JavaScript standards, enqueue API, defer/async, wp_add_inline_script js-css-standards.md
CSS standards, BEM, design tokens, Elementor CSS selectors js-css-standards.md
Elementor custom widget, Dynamic Tags, Loop Grid, Form actions, Theme Builder conditions elementor-patterns.md
Elementor extension points — custom form fields, theme locations, injecting controls into native widgets, adding a TAB to the editor's Elements panel, Finder/context-menu, hooks reference, deprecations elementor-extending.md
WooCommerce HPOS, order API, template overrides, Loop Grid for products woocommerce.md
REST API endpoints, schema, permission callbacks rest-api.md
Off-canvas UI, off-canvas accessibility, focus trap offcanvas-ui.md
Performance checklists (frontend + backend), speculative loading, IE conditional comments performance.md
Accessibility checklist, WCAG 2.2 AA, ARIA patterns performance.md
Hard-won production gotchas — widget lifecycle fatals, content_template() escaping, CSS-in-Elementor footguns, transactional email, wp.org review/packaging, embedding apps, AJAX/loop re-rendering, dynamic CSS in loops, Swiper on optimised sites, holding a login without counting it as failed field-notes.md
wordpress.org submission — the 18 Directory Guidelines, Plugin Check 2.1.0 categories/usage, review process, required headers/readme wp-org-guidelines.md
Subversion (SVN) — deploying/tagging a release on the wordpress.org plugin/theme SVN (trunk/tags/assets), the daily work cycle, branching/merging, properties, repo admin svn/svn.md (self-contained sub-bundle; svn/references/ goes deeper)
Debugging & static analysis — PHPCS+WPCS, PHPStan, Plugin Check, WP_DEBUG/Query Monitor, Elementor Safe Mode/cache, symptom→cause table debugging.md
Common WordPress APIs — admin settings page (Settings + Options API), register_meta, roles/capabilities, WP-Cron, internationalization (i18n) wordpress-apis.md
Database layer (MariaDB/MySQL) — custom tables + dbDelta(), charsets & the varchar(191) index rule, indexes, EXPLAIN, MariaDB↔MySQL portability, sql_mode, full-text mariadb.md

Widget Boilerplates — Load when building a widget of that type

Widget type Read this file
Button / CTA with icon and link widget-button.md
Container / layout wrapper / section / card shell widget-container.md
Image with caption, link, lightbox widget-image.md
Heading / title / HTML tag selector widget-heading.md
Rich text / WYSIWYG body content widget-text-editor.md
Video embed (YouTube, Vimeo, self-hosted) widget-video.md
Widget that renders a saved Elementor template by ID widget-elementor-template.md (SELECT2, get_builder_content_for_display(), Dynamic Tags context, CSS timing)
Widget whose markup lives in a separate PHP template file widget-php-template.md (Strategies A/B/C, load_template(), ob_start, path-traversal safety)
Divider / horizontal rule with optional text or icon widget-divider.md
Spacer / vertical gap widget-spacer.md
Single standalone icon with optional link widget-icon.md
Icon + title + description box widget-icon-box.md
Image + title + description box widget-image-box.md
Image grid / gallery widget-image-gallery.md
Image slider / carousel widget-image-carousel.md
Bullet list with icons per item widget-icon-list.md
Animated number counter widget-counter.md
Percentage progress bar widget-progress.md
Customer quote / testimonial widget-testimonial.md
Tabbed content panels widget-tabs.md
Accordion — one panel open at a time widget-accordion.md
Toggle — multiple panels open simultaneously widget-toggle.md
Social media icon links row widget-social-icons.md
Colored alert / notice box widget-alert.md
Audio player (SoundCloud / self-hosted) widget-audio.md
WordPress shortcode output widget-shortcode.md
Raw custom HTML / JS / CSS embed widget-html.md
Named anchor for in-page navigation widget-menu-anchor.md
WordPress registered sidebar output widget-sidebar.md
WordPress read more tag widget-read-more.md
Google Maps embed widget-google-maps.md
Decorative star rating display widget-star-rating.md
Schema-ready structured rating widget-rating.md
Text following a curved / custom SVG path widget-text-path.md
Nested Tabs / Nested Accordion (each panel is a Container holding any widgets) widget-nested.md

Always read the relevant sub-file before writing code. For tasks that span multiple areas (e.g. a WooCommerce widget with custom REST endpoint), read all relevant sub-files. For a widget task, read BOTH the widget boilerplate file AND elementor-patterns.md. For any custom widget, plugin, or wp.org-bound work, also skim field-notes.md — it catches the lint-passing, review-failing, site-down mistakes the topic files don't dwell on.


0. Golden Rules (Never Violate)

These override everything in all sub-files:

  1. Native APIs first — WordPress core hook before plugin; Elementor API before template override.
  2. Sanitize in, escape out — Every input sanitized. Every output escaped. No exceptions.
  3. Prefix everything — All functions, classes, constants, hooks, and CSS classes use a project-specific prefix.
  4. State your placement — Every code response must declare exactly where the code lives.
  5. No over-clarifying — Only ask a clarification question if the missing info would materially change the code output. Otherwise, state your assumption and proceed.
  6. NEVER hardcode visual settings in widgets — Every visual property (colors, fonts, sizes, spacing, backgrounds, borders, shadows, alignment) MUST be exposed as a standard Elementor control in the editor panel. Users control appearance via the toolbar — not by editing code. See §5 "Mandatory Widget Controls" below for the required controls checklist.
  7. Name for the directory from day one — Naming is decided first and is effectively irreversible, so get it right before writing code. The plugin slug/name must NOT start with a trademark you don't own — "CRM for WooCommerce", never "WooCommerce CRM" (Directory Guideline 17). The text domain must exactly equal the plugin slug. The wp.org slug is permanent, and a widget's get_name() is sticky (stored in every page's _elementor_data — renaming it breaks placed widgets). Choose the public name, slug, text domain, and code prefix (Rule #3) together, once. This is distinct from Rule #3: that governs internal code symbols; this governs the public identity. See wp-org-guidelines.md (Guidelines 12/16/17) and field-notes.md §1 (sticky get_name()).

1. Default Assumptions

Quickly assess — only ask if the answer would change the code:

Info needed Ask only if...
Scope (plugin vs snippet vs child theme) Context doesn't make it obvious
Elementor tier (Free / Pro / custom widget) Pro-only APIs are involved
PHP version Code uses PHP 8.3+ features like typed class constants, or 8.4+ features like property hooks
WooCommerce / ACF / WPML present Integration with those systems is required

Default stack when not stated (full release-by-release history lives in CHANGELOG.md — keep volatile version-tracking out of this router):

Component Version Notes
WordPress 7.1 "Mary Lou", released Aug 19, 2026 (7.0 "Armstrong" was May 20; the 7.0 branch ended at 7.0.4, Aug 12). Minimum PHP 7.4 (7.2/7.3 dropped — sites still on them stay pinned to 6.9.x). No multisite assumed. 7.1 makes the post editor iframe unconditional — see below.
PHP 8.3 recommended 7.4 = minimum. 8.4 / 8.5 = "beta support" (possible deprecation notices). 8.2 fully compatible but no longer the recommended default.
Elementor (free + Pro) 4.2+ Separate plugins with independent version numbers — currently free 4.2.3 / Pro 4.2.2 (both Aug 19, 2026). 4.0.0 (Mar 30, 2026) made the Atomic Editor stable + default for new installs; 4.2.0 (Jul 20) added Atomic Grid (free) and Atomic Loop (Pro). ⚠️ Elementor's header still reads "Tested up to: 7.0.4" — it has not declared WP 7.1 support. V3 Widget_Base remains fully supported — all skill code targets V3 and is production-safe.
WooCommerce 11.0+ 11.0 (Aug 4, 2026) REMOVED the product editor beta and bumped to Action Scheduler 4.0.0 (breaking dedup change). HPOS default-on since 8.2; 10.7 disabled HPOS "sync on read"; 10.9 defers Store API draft-order creation — see woocommerce.md.

Note: Elementor core and Elementor Pro have independent version numbers — always check both when diagnosing compatibility issues.

WordPress 7.1 — what changed for plugin / Elementor devs

WP 7.1 "Mary Lou" shipped August 19, 2026 (WordCamp US). Unlike 7.0, this release contains four changes that can break a shipped plugin — they are listed first. No PHP or WordPress minimum changed.

🔴 Breaking — audit your plugin for these:

  1. The post editor is now ALWAYS in an iframe — including on sites registering legacy meta boxes, which was the last remaining escape hatch. Editor JavaScript that reaches for the global document / window now targets the wrong document. Use the canvas element's ownerDocument and defaultView instead:
    // ❌ breaks in 7.1 — this is the OUTER document, not the editor canvas
    document.querySelector( '.my-block' ).classList.add( 'ready' );
    
    // ✅ resolve the document from a node you already own inside the canvas
    const doc  = myCanvasNode.ownerDocument;
    const view = doc.defaultView;                   // the iframe's `window`
    doc.querySelector( '.my-block' )?.classList.add( 'ready' );
    view.requestAnimationFrame( … );
    
    Injected <style>/<link> must go into the canvas document too, or it styles nothing. Elementor's own editor is unaffected (it is not the block editor) — this hits block registrations, meta boxes, and any block-editor integration your plugin ships.
  2. __next40pxDefaultSize is now a no-op on @wordpress/components — remove the prop entirely; there is no replacement. Form controls render at 40px unconditionally.
  3. List-table markup changed: the row header moved from the checkbox column to the title column. Any CSS/JS selector keyed on th.check-column (or assuming the row header's position) breaks — audit custom admin list tables and column callbacks.
  4. jQuery UI updated to 1.14.2. Test anything depending on jQuery UI behaviour or styling.

🟢 New and useful:

  • SVG Icon API (public in 7.1) — register icons and whole collections, render server-side, read over REST: wp_register_icon_collection(), wp_register_icon(), wp_get_icon(). ⚠️ SVGs pass through a conservative allowlist — only <svg>, <path> and <polygon> survive. A <g>, <circle>, <rect> or <use> in your icon is stripped silently, so convert shapes to paths before registering. (This is a core API — Elementor widgets still ship their own SVGs; see elementor-extending.md §8 and the "don't wp_kses() inline SVG" rule in field-notes.md §6.)
  • Abilities API matured: wp_get_abilities() filtering, execution-lifecycle hooks, custom validation, a unified public exposure flag, and client-compatible JSON-Schema preparation. Registration timing rules from 7.0 are unchanged (see the 7.0 section below).
  • Global Styles / theme.json: responsive style variations with configurable breakpoints, pseudo-state styling (:hover / :focus / :active), and text-shadow support.
  • Media: client-side image processing in the browser via WebAssembly (compression/resizing no longer hit the server), infinite scroll in the Media Library, and registration of multiple image sizes at once. Multisite now enforces upload limits on media sideloading.
  • Persistent admin bar across editor screens — review any custom toolbar nodes for behaviour in the editor context.
  • Design System: semantic design tokens for theming admin UI (relevant if you build admin or editor panels — compare the Elementor panel tokens in elementor-extending.md §5).
  • DataViews / DataForm APIs matured for data-driven admin interfaces.
  • React stays on 18.3 — React 19 was deferred again. Don't bundle react/jsx-runtime yourself, and avoid string refs / defaultProps, which fail under the experimental React 19 flag.
  • Conditional block-CSS loading can affect remote content pulls — verify if you render blocks outside a normal page request.

Sources: wordpress.org/news/2026/08/mary-lou/ · make.wordpress.org/core/2026/08/05/wordpress-7-1-field-guide/ · developer.wordpress.org/news/2026/08/whats-new-for-developers-august-2026/

WordPress 7.0 — what changed for plugin / Elementor devs

WP 7.0 "Armstrong" shipped May 20, 2026 (delayed from the original April 9 target while the RTC storage layer was redesigned — see below). Everything below is opt-in and non-breaking; most plugin/Elementor work is unaffected. It remains relevant because 7.1 changed none of it.

  • Minimum PHP is now 7.4 (7.2/7.3 dropped). The skill's recommended baseline stays PHP 8.3. Bump your plugin's Requires PHP header to 7.4 only once you target WP 7.0+ exclusively. No new DB minimum is enforced; wordpress.org/about/requirements/ recommends MariaDB 10.11+ or MySQL 8.0+ (the MariaDB floor was raised from 10.6; re-verified Aug 2026).
  • Real-Time Collaboration (RTC): simultaneous multi-author block editing (CRDT-based, via an HTTP-polling sync provider — not WebRTC). Data is stored in a dedicated core database table; an earlier wp_post_meta / wp_sync_storage design was rejected, and building the table is what pushed the release from April to May. Plugin impact: scope every WP_Query / get_posts() with an explicit post_type so internal core post types never leak into your results — do not hardcode any internal RTC type name. The WP_ALLOW_COLLABORATION constant lets hosts swap the sync transport.
  • WP AI Client: provider-agnostic PHP + JS AI API — wp_ai_client_prompt( $prompt )->generate_text(). Guard with function_exists( 'wp_ai_client_prompt' ).
  • Abilities API: wp_register_ability() (PHP, since WP 6.9) plus a JS counterpart in 7.0. Use 'meta' => ['show_in_rest' => true] to expose via REST. Register ONLY inside the wp_abilities_api_init hook (core renamed the feature plugin's abilities_api_init; categories go on wp_abilities_api_categories_init) or you get _doing_it_wrong; a category is required and must already be registered. Calling it also sets your real Requires at least floor to 6.9 — Plugin Check errors on the header and function_exists() guards don't count (see debugging.md §1).
  • Connectors UI (Settings → Connectors) for managing AI provider credentials, and a Command Palette in wp-admin.
  • Iframed editor was still conditional in 7.0 — 7.1 makes it unconditional. See the 7.1 section above; "apiVersion": 3 in block.json is now table stakes, not preparation.

Sources: make.wordpress.org/core/2026/01/09/dropping-support-for-php-7-2-and-7-3/ · make.wordpress.org/core/2026/04/22/wordpress-7-0-release-party-updated-schedule/ · wordpress.org/about/requirements/

📌 PHP support labels (unchanged in WP 7.1): PHP 7.4–8.3 fully compatible; 8.3 recommended; 8.4 (WP 6.7+) and 8.5 (WP 6.9+) carry a "beta support" label (possible deprecation notices). Source: make.wordpress.org/core/handbook/references/php-compatibility-and-wordpress-versions/

✅ Elementor 4.x status (current: free 4.2.3 / Pro 4.2.2, both Aug 19, 2026): Elementor 4.0.0 (Mar 30, 2026, free + Pro) made the Atomic Editor stable and the default for new installs and added Atomic Forms, Pro Interactions, and Component creation. Updating to 4.x leaves existing sites untouched — V3 widgets and V4 Atomic Elements coexist on the same page; Atomic features are toggled at WP Admin → Elementor → Editor → Settings.

There is still NO third-party API for building Atomic Elements, and Elementor has said so explicitly — they will not release one soon and advise against integrating with Atomic internals until they announce it (elementor/elementor GitHub Discussion #32950). Atomic Elements are documented only as a data structure (developers.elementor.com/docs/data-structure/atomic-elements). So continue using V3 Widget_Base for all third-party widgets — it is the correct, production-safe API and all skill code targets it. (Re-verified Aug 23, 2026 — unchanged since the July check.)

⚠️ Elementor has not declared WP 7.1 compatibility yet. As of free 4.2.3 / Pro 4.2.2 the Tested up to header reads 7.0.4, three days after WP 7.1 shipped. That is normal lag, not a known incompatibility — but when a client reports editor breakage on a fresh 7.1 site, check Elementor's current Tested up to before debugging your own code.

V4 Atomic Elements that now ship by default (awareness only — not third-party-buildable yet): Div Block & Flexbox Container (layout); Atomic Grid — advanced row/column layouts (free 4.2.0, Jul 20, 2026); Atomic Heading, Paragraph, Image, Button, Video, SVG; Atomic Tabs; Atomic Forms (Pro) with composable fields — Label, Input, Textarea, Checkbox, Submit, plus Radio, Select, Date Picker, Time Picker, and File Upload (added in Pro 4.1.0, May 26, 2026); and Atomic Loop — dynamic content layouts, the V4 counterpart of the Pro Loop Grid (Pro 4.2.0, Jul 20, 2026). These are end-user elements; building custom atomic elements still awaits the finalized V4 extension docs — keep targeting V3 Widget_Base until then. Source: elementor.com/products/website-builder/v4-faq/ · developers.elementor.com/elementor-editor-4-0-developers-update/ · elementor.com/pro/changelog/ · github.com/elementor/elementor/releases

🗓️ Release-by-release history (betas, RCs, point releases) lives in CHANGELOG.md. Keep this router focused on durable guidance; update version facts in the table above and in CHANGELOG.md, not scattered across the sub-files.


2. Architecture Decision Tree

Run through this mentally before writing a single line:

Does a WordPress core hook (add_action/add_filter) solve it?
  YES → Use the hook. No plugin needed. Place in child theme functions.php
        or Elementor Custom Code.
  NO  → Does Elementor's PHP/JS API solve it?
          YES → Extend via Elementor hooks, Dynamic Tags, or Widget_Base.
                Register via elementor/widgets/register or elementor/dynamic_tags/register.
          NO  → Is this logic reusable across themes or sites?
                  YES → Scaffold a dedicated plugin (see scaffolding.md).
                  NO  → Child theme functions.php or Elementor Custom Code block.

Is a WooCommerce override needed?
  → Use Elementor Loop Grid + custom query filter BEFORE touching template files.
  → Only override woocommerce/ templates as an absolute last resort.

Is this a REST API endpoint?
  → Always register via register_rest_route() inside a plugin, never in functions.php.

3. Mandatory Output Format

Every single code response must follow this structure — no exceptions:

📍 PLACEMENT
Exact file path or Elementor hook location.
e.g. /wp-content/plugins/myplugin/includes/class-myplugin-hooks.php
     Elementor → Site Settings → Custom Code → wp_footer

⚙️ REQUIRES
WordPress X.X+ | PHP X.X+ | Elementor Pro X.X+ | ACF X.X+ | WooCommerce X.X+
(list only what the code actually depends on)

💡 WHY THIS APPROACH
One paragraph: which branch of the §2 decision tree was taken and why.

📋 CODE
Complete, commented, deployment-ready code block — no truncation, no omissions.

🔧 INTEGRATION NOTES (include when relevant)
Any manual steps required: flush rewrite rules, set Query ID in Elementor editor,
activate plugin, clear Elementor cache, etc.

4. Quick Reference — Pattern Index

Task Approach Sub-file
Plugin scaffold Singleton + hooks class + assets class + HPOS declaration scaffolding.md
Child theme setup wp_enqueue_style parent + child in functions.php scaffolding.md
Custom Post Type + Taxonomy register_post_type() + register_taxonomy() in plugin scaffolding.md
Secure AJAX handler wp_ajax_ hooks + nonce verify + wp_send_json_* scaffolding.md
PHP sanitization / escaping wp_unslash() + sanitize_* + esc_* patterns php-standards.md
Transient caching get_transient / set_transient php-standards.md
External API call + WP_Error wp_remote_get() + WP_Error pattern php-standards.md
Admin settings page Settings API + register_setting + sanitize_callback + settings_fields wordpress-apis.md
Store plugin options Options API + explicit boolean autoload (WP 6.6+) wordpress-apis.md
Custom field exposed to REST / Elementor register_post_meta + show_in_rest wordpress-apis.md
Scheduled / background task WP-Cron (wp_schedule_event) + Action Scheduler for heavy jobs wordpress-apis.md
Create a custom DB table dbDelta() + $wpdb->prefix + get_charset_collate(); strict formatting rules mariadb.md
"Specified key was too long" / emoji not saving utf8mb4 + index string columns at varchar(191) mariadb.md
Slow query / missing index EXPLAIN (type: ALL, key: NULL, Using filesort) + leftmost-prefix rule mariadb.md
SQL that must run on MariaDB and MySQL Plain standard SQL; JSON is stored/compared differently — don't query inside it mariadb.md
Make a plugin translatable i18n functions + text-domain = slug + WP 6.7 init-timing rule wordpress-apis.md
Ship a translation for a wp.org-hosted plugin .pot only in the zip; translate.wordpress.org → language packs (90% gate). Never bundle .mo / call load_plugin_textdomain() wordpress-apis.md
Style UI text for non-Latin locales Caseless scripts + text-transform/letter-spacing; don't size to English string length js-css-standards.md
Test that translations actually load Set the site locale before bootstrap; unload_textdomain() in WP-CLI; validate placeholders debugging.md
Deploy / tag a plugin release on wp.org SVN svn co → copy to trunk/ → svn cp trunk tags/X.Y.Z → svn ci (assets/ is a sibling) svn/svn.md
Any SVN task (checkout, commit, branch, merge, conflicts, repo admin) Subversion sub-bundle svn/svn.md
WP 6.8 password hashing wp_check_password() + wp_password_needs_rehash() php-standards.md
WP 6.8 app password / key hashing wp_fast_hash() + wp_verify_fast_hash() (BLAKE2b) php-standards.md
JS standards + enqueue defer/async IIFE + WP 6.3+ enqueue API js-css-standards.md
PHP → JS data passing wp_add_inline_script() with wp_json_encode() js-css-standards.md
CSS BEM + design tokens Scoped tokens, 8pt spacing, fluid type js-css-standards.md
Custom Elementor widget Widget_Base + all required methods elementor-patterns.md
Elementor Dynamic Tag Tag class + elementor/dynamic_tags/register elementor-patterns.md
Elementor Loop Grid query elementor/query/ filter elementor-patterns.md
ACF field in Elementor Dynamic Tag extending \Elementor\Core\DynamicTags\Tag elementor-patterns.md
Elementor Pro Form action Action_Base + field iteration elementor-patterns.md
Theme Builder custom condition Condition_Base + elementor/theme/register_conditions elementor-patterns.md
Elementor Pro custom form field Field_Base + elementor_pro/forms/fields/register elementor-extending.md
Theme Builder custom location register_location + elementor_theme_do_location() elementor-extending.md
Add a control to a NATIVE Elementor widget elementor/element/{el}/{section}/before_section_end elementor-extending.md
Filter a native widget's output elementor/widget/render_content filter elementor-extending.md
Add a TAB to the editor panel (like an SEO plugin) elementorV2.editorElementsPanel.injectTab() + elementor/editor/before_enqueue_scripts elementor-extending.md
WooCommerce HPOS compatibility FeaturesUtil::declare_compatibility() + wc_get_order() woocommerce.md
WooCommerce loop Loop Grid + elementor/query/ filter woocommerce.md
Custom REST endpoint register_rest_route() + schema callback in plugin rest-api.md
Off-canvas filter panel BEM CSS + ARIA JS + Custom Code block offcanvas-ui.md
Performance checklist Frontend + backend checklists performance.md
Accessibility checklist WCAG 2.2 AA patterns performance.md
Elementor dependency check Requires Plugins: elementor header + did_action fallback scaffolding.md
Widget with button/CTA Controls + render matching native Button widget widget-button.md
Widget with container/layout Controls + render matching native Container widget-container.md
Widget with image Group_Control_Image_Size + get_attachment_image_html() widget-image.md
Widget with heading TEXTAREA + header_size tag selector widget-heading.md
Widget with rich text WYSIWYG + wp_kses_post output widget-text-editor.md
Widget with video embed Source SELECT + overlay + aspect ratio widget-video.md
Widget rendering saved Elementor template get_builder_content_for_display() + SELECT2 widget-elementor-template.md
Widget with PHP template file load_template() + locate_template() strategies A/B/C widget-php-template.md
Widget with divider line Style + width + optional text/icon element widget-divider.md
Widget with spacer gap Single responsive SLIDER widget-spacer.md
Widget with single icon ICONS control + size + color tabs widget-icon.md
Widget with icon + text box Icon + title + description pattern widget-icon-box.md
Widget with image + text box Image + title + description pattern widget-image-box.md
Widget with image grid GALLERY control + Group_Control_Image_Size widget-image-gallery.md
Widget with image slider GALLERY + Swiper + navigation controls widget-image-carousel.md
Widget with icon bullet list REPEATER + icon + text + optional link widget-icon-list.md
Widget with animated counter Number + prefix/suffix + duration widget-counter.md
Widget with progress bar Percentage SLIDER + bar styling widget-progress.md
Widget with testimonial quote Content + image + name + job title widget-testimonial.md
Widget with tabbed panels REPEATER tabs + horizontal/vertical type widget-tabs.md
Widget with accordion REPEATER + single-open collapse pattern widget-accordion.md
Widget with toggle panels REPEATER + multi-open toggle pattern widget-toggle.md
Widget with social icons REPEATER + brand icons + links widget-social-icons.md
Widget with alert/notice box Type SELECT + title + description + dismiss widget-alert.md
Widget with audio player SoundCloud URL + autoplay options widget-audio.md
Widget outputting shortcode TEXTAREA + do_shortcode() widget-shortcode.md
Widget with raw HTML embed CODE control + unescaped output widget-html.md
Widget as named anchor TEXT ID + sanitize_html_class() widget-menu-anchor.md
Widget outputting sidebar Registered sidebar SELECT + dynamic_sidebar() widget-sidebar.md
Widget with read more tag No controls — WordPress $more global widget-read-more.md
Widget with Google Maps Address TEXT + zoom SLIDER + iframe widget-google-maps.md
Widget with star rating display Scale + rating number + icon style widget-star-rating.md
Widget with schema rating Icon count + fractional rating + gap widget-rating.md
Widget with curved/path text <svg> + <textPath> + unique path id widget-text-path.md
Nested Tabs / Accordion widget Widget_Nested_Base + print_child() + container panels widget-nested.md

5. Mandatory Widget Controls — No Hardcoded Visuals (NEVER VIOLATE)

This section is MANDATORY for every custom Elementor widget. Whenever you build a widget, every visual property must be an Elementor control — NEVER a hardcoded CSS value. Users control appearance from the editor panel/toolbar, not by editing source code.

The Rule

NEVER hardcode any of the following in PHP render(), in static CSS, or in content_template() output:

  • Colors (text, background, border, shadow)
  • Typography (font family, size, weight, line-height, letter-spacing, transform)
  • Spacing (padding, margin, gap)
  • Sizing (width, height, min/max values)
  • Borders (style, width, color, radius)
  • Shadows (box-shadow, text-shadow)
  • Backgrounds (color, gradient, image)
  • Alignment / positioning
  • Opacity, transitions, hover effects

ALL of the above must use Elementor controls with selectors that inject CSS dynamically. The only exceptions are structural CSS (display, position, overflow) required for the widget layout to function at all — and even these should use controls when there is a user-facing choice (e.g. flex-direction toggle).

⚠️ "No hardcoded visuals" does NOT mean "put a default on every control." Elementor emits a default exactly like a user-set value, so a colour default paints on every install and clearing the swatch only restores it — the user cannot turn it off. Elementor core carries no default on colour controls at all (includes/widgets/heading.php: title_color uses a 'global' reference; title_hover_color is entirely empty). Follow that split:

  • Sizes, spacing, alignment, toggles, tags, labels → give a real 'default'.
  • Colours → leave empty. Put the resting look in your stylesheet's var(--token, fallback) and let the control override it. For state colours (hover / active / selected / current) also write a direct CSS property, never a custom property, and keep that state colour-free in your CSS — otherwise "empty" still paints the fallback.

See field-notes.md §4 for the full trap, plus the theme-specificity and accent-color cases.

Required Controls Checklist — Apply to Every Widget

When building a widget, include ALL controls that apply to its visual elements. Use this checklist as a mandatory gate:

Visual property Required Elementor control Tab
Text content TEXT, TEXTAREA, or WYSIWYG + 'dynamic' => ['active' => true] TAB_CONTENT
Typography (any text element) add_group_control( Group_Control_Typography::get_type() ) TAB_STYLE
Text color COLOR control with selectors TAB_STYLE
Text alignment add_responsive_control() with CHOOSE (left/center/right/justify) TAB_STYLE or TAB_CONTENT
Background add_group_control( Group_Control_Background::get_type() ) TAB_STYLE
Border add_group_control( Group_Control_Border::get_type() ) TAB_STYLE
Border radius add_responsive_control() with DIMENSIONS + 'selectors' TAB_STYLE
Box shadow add_group_control( Group_Control_Box_Shadow::get_type() ) TAB_STYLE
Text shadow add_group_control( Group_Control_Text_Shadow::get_type() ) TAB_STYLE
Padding add_responsive_control() with DIMENSIONS TAB_STYLE
Margin add_responsive_control() with DIMENSIONS TAB_STYLE
Width / Height add_responsive_control() with SLIDER TAB_STYLE
Spacing / Gap add_responsive_control() with SLIDER TAB_STYLE
Image MEDIA + add_group_control( Group_Control_Image_Size::get_type() ) TAB_CONTENT
CSS Filters (if image/element) add_group_control( Group_Control_Css_Filter::get_type() ) TAB_STYLE
Hover state Duplicate color/background/shadow controls inside 'section_style_hover' with selectors targeting :hover TAB_STYLE
Transition duration SLIDER (seconds) with selectors => ['transition-duration'] TAB_STYLE
Link URL control with 'dynamic' => ['active' => true] TAB_CONTENT
Icon ICONS control with fa4compatibility TAB_CONTENT
HTML tag SELECT (h1–h6, div, span, p) TAB_CONTENT

Example — Correct vs Incorrect

// ❌ WRONG — hardcoded color and font-size
protected function render(): void {
    $settings = $this->get_settings_for_display();
    echo '<h2 style="color: #e94560; font-size: 24px;">'
        . esc_html( $settings['title'] ) . '</h2>';
}

// ✅ CORRECT — all visuals controlled from the panel via selectors
protected function register_controls(): void {
    // ... Content section with title TEXT control ...

    $this->start_controls_section( 'section_title_style', [
        'label' => esc_html__( 'Title Style', 'myplugin' ),
        'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
    ] );

    $this->add_control( 'title_color', [
        'label'     => esc_html__( 'Color', 'myplugin' ),
        'type'      => \Elementor\Controls_Manager::COLOR,
        'selectors' => [
            '{{WRAPPER}} .myplugin-widget__title' => 'color: {{VALUE}};',
        ],
    ] );

    $this->add_group_control(
        \Elementor\Group_Control_Typography::get_type(),
        [
            'name'     => 'title_typography',
            'selector' => '{{WRAPPER}} .myplugin-widget__title',
        ]
    );

    $this->add_responsive_control( 'title_align', [
        'label'   => esc_html__( 'Alignment', 'myplugin' ),
        'type'    => \Elementor\Controls_Manager::CHOOSE,
        'options' => [
            'left'   => [ 'title' => esc_html__( 'Left',   'myplugin' ), 'icon' => 'eicon-text-align-left' ],
            'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ],
            'right'  => [ 'title' => esc_html__( 'Right',  'myplugin' ), 'icon' => 'eicon-text-align-right' ],
        ],
        'selectors' => [
            '{{WRAPPER}} .myplugin-widget__title' => 'text-align: {{VALUE}};',
        ],
    ] );

    $this->add_responsive_control( 'title_spacing', [
        'label'      => esc_html__( 'Bottom Spacing', 'myplugin' ),
        'type'       => \Elementor\Controls_Manager::SLIDER,
        'size_units' => [ 'px', 'em', 'rem' ],
        'range'      => [ 'px' => [ 'min' => 0, 'max' => 100 ] ],
        'selectors'  => [
            '{{WRAPPER}} .myplugin-widget__title' => 'margin-bottom: {{SIZE}}{{UNIT}};',
        ],
    ] );

    $this->end_controls_section();
}

protected function render(): void {
    $settings = $this->get_settings_for_display();
    // ✅ No inline styles — all visuals come from Elementor's selectors
    $this->add_render_attribute( 'title', 'class', 'myplugin-widget__title' );
    $this->add_inline_editing_attributes( 'title' );
    echo '<h2 ' . $this->get_render_attribute_string( 'title' ) . '>'
        . esc_html( $settings['title'] ) . '</h2>';
}

add_render_attribute() and add_inline_editing_attributes() — Always Use

The official Elementor API for building HTML attributes is $this->add_render_attribute(). Always use it instead of manually concatenating class/id/aria attributes in render(). Pair with $this->add_inline_editing_attributes() for any text field that supports live editing in the Elementor editor panel.

Outputting the built attributes — pick by context: use get_render_attribute_string( 'key' ) when you are concatenating into a string (as in the echo '<h2 ' . ... . '>' example above), and print_render_attribute_string( 'key' ) when you are echoing directly inside a ?> … <?php HTML block (e.g. <h2 <?php $this->print_render_attribute_string( 'title' ); ?>>). Both are correct Elementor APIs — print_* simply echoes what get_* returns. The widget sub-files use the print_* form inside their HTML templates.

Source: developers.elementor.com/docs/widgets/rendering-html-attribute/ Source: developers.elementor.com/docs/widgets/rendering-inline-editing/

Files (wordpress-elementor-skill)
  • svn
    • references
      • branching-tagging-merging.md 12.3 KB
        # Branching, Tagging, and Merging
        
        Deep reference for SVN's branch/tag/merge model. Read `svn.md` first for
        the mental model and the basic `svn copy` mechanics — this file covers
        everything past creating a simple branch or tag.
        
        ## Table of contents
        - Why branch at all
        - Creating and working with a branch
        - Moving a working copy between branches (svn switch)
        - Basic merging (sync merges)
        - Mergeinfo: what it is and how to read it
        - Reintegrating a branch back to trunk
        - Undoing a bad commit
        - Resurrecting deleted files or directories
        - Common branching patterns
        - To branch or not to branch
        
        ## Why branch at all
        
        The alternative to branching is working in isolation in a single working
        copy for days or weeks without committing. That's risky (one lost laptop
        loses everything), inflexible (hard to share work-in-progress for review),
        and it makes the eventual merge back much harder the longer you wait. A
        branch gives you a place to commit incrementally — safe, reviewable,
        shareable — without disturbing trunk.
        
        ## Creating and working with a branch
        
        A branch is a directory created with a server-side `svn copy`. Copying on
        the server is a constant-time "cheap copy" — SVN doesn't duplicate any
        data, it just adds a directory entry pointing at an existing tree — so
        branch creation is fast regardless of project size:
        
        ```bash
        svn copy https://example.com/repo/calc/trunk \
                 https://example.com/repo/calc/branches/my-feature \
                 -m "Creating a private branch of /calc/trunk."
        ```
        
        Do this as a **remote** copy (URL to URL), not by copying a directory
        inside a working copy — a working-copy-side copy has to physically
        duplicate every file on local disk and is much slower.
        
        Check out the branch to work on it:
        ```bash
        svn checkout https://example.com/repo/calc/branches/my-feature
        ```
        It behaves like any other working copy. Commits to it are invisible to
        people working on trunk (and vice versa) until someone merges.
        
        Because SVN has no native branch concept — only copies — running `svn log
        -v` on a file inside your branch shows its full history, including the
        copy event itself and everything that happened to the file on trunk before
        the branch point. This is normal and useful for auditing where a branch
        came from.
        
        ### Moving an existing working copy between branches
        
        Checking out a fresh working copy per branch isn't the only option.
        `svn switch` re-points a working copy you already have at a different URL,
        downloading only what's different:
        ```bash
        svn switch ^/calc/branches/my-feature
        ```
        It's a superset of `svn update` — an update moves a working copy through
        time (same URL, newer revision); a switch moves it through time *and*
        space (different URL). Local edits survive the switch, which is a handy
        escape hatch if you've been editing trunk and realize partway through that
        the work belongs on a branch (or vice versa): switch, then commit. You can
        also switch just part of a working copy — a single subdirectory or even
        one file — without touching the rest.
        
        Don't confuse plain `svn switch` with `svn switch --relocate` (deprecated
        since 1.7 in favor of the separate `svn relocate` command): relocate is
        for when the repository's own URL changed — same content, new address —
        not for moving between branches.
        
        ## Basic merging (sync merges)
        
        **Requires Subversion 1.5+ client and server** for automatic merge
        tracking; strongly recommend 1.7+ on both ends (most merge-tracking bug
        fixes are client-side, so an old server with a new client is fine).
        
        While you work on a branch, trunk keeps moving. Periodically pull trunk's
        changes into your branch to avoid drifting so far apart that the eventual
        merge back is painful:
        
        ```bash
        cd my-feature-branch      # must be a CLEAN working copy — svn status shows nothing pending
        svn merge ^/calc/trunk
        ```
        `^/` is shorthand (1.6+) for the repository root, so you don't have to
        type the full URL. Output looks like:
        ```
        --- Merging r345 through r356 into '.':
        U    button.c
        U    integer.c
        --- Recording mergeinfo for merge of r345 through r356 into '.':
         U   .
        ```
        The "Recording mergeinfo" lines are SVN silently updating the
        `svn:mergeinfo` property on the target — this is how it remembers what's
        already been merged so the *next* sync merge only pulls in what's new. Do
        not hand-edit this property.
        
        **Important 1.7+ restriction:** `svn merge` refuses to run against a
        mixed-revision working copy (`Cannot merge into mixed-revision working
        copy`). Run `svn update` first.
        
        After merging, build/test, then commit like any other change:
        ```bash
        svn commit -m "Merged latest trunk changes to my-feature."
        ```
        
        ### Merging without automatic tracking (pre-1.5 servers)
        
        If your server predates 1.5, none of the above bookkeeping happens
        automatically. You must track merged ranges yourself, typically by noting
        them in commit log messages, and merge explicit revision ranges:
        ```bash
        svn merge ^/trunk -r399:HEAD
        ```
        where 399 is the revision trunk was at when the branch was created (or the
        end of the last manual merge). This is painful — a strong reason to insist
        on 1.5+ everywhere.
        
        ## Mergeinfo: what it is and how to read it
        
        `svn:mergeinfo` is the property SVN uses to track which changesets have
        already been replicated into a given path. Inspect it directly:
        ```bash
        svn propget svn:mergeinfo .
        /trunk:341-390
        ```
        Or ask SVN to interpret it for you:
        ```bash
        svn mergeinfo ^/calc/trunk              # revisions already merged from trunk
        svn mergeinfo ^/calc/trunk --show-revs eligible   # revisions NOT yet merged (a preview)
        ```
        `svn mergeinfo` needs a source URL and takes an optional target URL
        (defaults to the current working directory).
        
        **Mergeinfo inheritance:** a path with the property explicitly set has
        *explicit mergeinfo*, which its children inherit unless a child has its own
        explicit mergeinfo (which always wins outright — explicit mergeinfo never
        blends with inherited mergeinfo). A `*` suffix on a revision number in
        mergeinfo output (e.g. `758*`) marks it as only *partially* merged —
        merging it again would still produce additional changes.
        
        **Subtree merges:** merging directly into some child of a branch root
        (rather than the root itself) creates "subtree mergeinfo." It's handled
        automatically but can make `svn propget svn:mergeinfo --recursive` output
        large and confusing on complex repositories; use the `-v`/`--verbose` flag
        for a more readable breakdown.
        
        **Preview a merge without applying it:**
        ```bash
        svn merge ^/calc/trunk --dry-run
        ```
        Shows the status codes a real merge would produce without touching the
        working copy. If you don't like the result of a real (non-dry-run) merge
        before committing, `svn revert . -R` undoes it completely and you can
        retry with different options — the merge isn't final until you commit.
        
        ## Reintegrating a branch back to trunk
        
        Once your feature is done:
        
        1. Do one final sync merge (pull latest trunk into the branch), build,
           test, commit.
        2. Get a clean, up-to-date, single-revision working copy of **trunk**
           (not the branch) — a fresh checkout is simplest.
        3. From that trunk working copy:
        ```bash
        svn merge --reintegrate ^/calc/branches/my-feature
        # build, test, verify
        svn commit -m "Merge my-feature back into trunk!"
        ```
        
        `--reintegrate` is a special-purpose flag: instead of replaying a
        contiguous range of revisions (like a normal sync merge), it diffs the
        latest trunk tree against the latest branch tree and applies exactly that
        difference. It's required specifically for merging a branch *back* to its
        parent, and it's picky:
        
        - The trunk working copy must have **no local edits and no mixed
          revisions** — this is enforced, not just recommended.
        - It only accepts a small set of other options: `--accept`, `--dry-run`,
          `--diff3-cmd`, `--extensions`, `--quiet`.
        - **A branch that has been `--reintegrate`d cannot be usefully reused** —
          it can't cleanly absorb further trunk changes or be reintegrated again.
          After a successful reintegration, either delete the branch and recreate
          it fresh from trunk if you need to keep working:
          ```bash
          svn delete ^/calc/branches/my-feature -m "Remove my-feature, reintegrated in r391."
          svn copy ^/calc/trunk ^/calc/branches/my-feature -m "Recreate my-feature from trunk@HEAD."
          ```
          or use the "keep it alive" technique described in the SVN Book's Advanced
          Merging section (merge trunk into the branch once more immediately after
          reintegration, before continuing further branch work).
        
        **Version note:** everything above describes 1.5–1.7 behavior, where you
        must pass `--reintegrate` explicitly (this skill's source material is the
        1.7 book). Since 1.8, `svn merge` detects a reintegrate situation
        automatically and the flag is deprecated — `svn merge
        ^/calc/branches/my-feature` (no flag) does the right thing on its own from
        a trunk working copy. Given how old 1.7 now is, assume auto-detection
        unless `svn --version` confirms an older client; passing `--reintegrate`
        explicitly on a modern client is harmless but unnecessary.
        
        Deleting a merged-in branch does not lose history — `svn log` on the
        `branches/` URL still shows it, and it can be resurrected later (see
        below) if needed.
        
        ## Undoing a bad commit
        
        `svn merge` can apply a changeset *backward*, which is how you roll back a
        specific revision without touching anything else:
        ```bash
        svn merge -c -303 ^/calc/trunk    # reverse-apply r303
        svn status                        # review
        svn diff                          # confirm the change is actually gone
        svn commit -m "Undoing change committed in r303."
        ```
        `-c -303` is shorthand for `-r 303:302`. This doesn't erase r303 from
        history — anyone checking out an old revision between 303 and your revert
        commit still sees the bad change — it only removes it from `HEAD` going
        forward. True history deletion isn't supported by design (SVN revisions
        are immutable, append-only trees); the closest tool for permanently
        scrubbing something (e.g. an accidentally committed secret) is
        `svndumpfilter`, covered in `repository-admin.md`.
        
        ## Resurrecting deleted files or directories
        
        Two-step process: find the coordinate (revision + path), then copy it back.
        The `@807` syntax below is a *peg revision* — it tells SVN which object
        you mean (in case the path `real.c` refers to something different at
        different points in history, e.g. it was deleted and something unrelated
        was later added at the same path), as opposed to a plain `-r` revision,
        which says which point in *that* object's own history you want. For a
        straightforward resurrection like this they'll usually agree, but the peg
        is what pins down the right one if a path has a complicated past.
        
        1. Find when it was deleted:
        ```bash
        svn log -v parent-dir/    # look for a D (deleted) entry for the path you want back
        ```
        If it was deleted in revision 808, the last good version is at 807.
        
        2. Bring it back — two options depending on whether you want to keep the
           historical link:
        ```bash
        # Preserves full history (shows as "added with history", a '+' in status):
        svn copy ^/calc/trunk/real.c@807 ./real.c
        svn commit -m "Resurrected real.c from revision 807."
        
        # Fresh start, no historical link:
        svn cat ^/calc/trunk/real.c@807 > ./real.c
        svn add real.c
        svn commit -m "Re-created real.c from revision 807."
        ```
        This also works entirely server-side, without a working copy:
        ```bash
        svn copy ^/calc/trunk/real.c@807 ^/calc/trunk/ -m "Resurrect real.c from revision 807."
        ```
        
        Prefer `svn copy` (not `svn merge -c -REV` reverse-applied) when only one
        file among several changed in the deleting revision needs to come back —
        reverse-merging the whole revision would also undo the other unrelated
        changes it contained.
        
        ## Common branching patterns
        
        - **Release branches.** Cut a branch when a version is about to ship
          (`branches/1.2.x`), so trunk can keep moving forward on new features
          while the release branch only receives stabilization fixes. Tag specific
          point releases (`tags/1.2.0`, `tags/1.2.1`) off the release branch as
          they ship.
        - **Feature branches.** Cut a branch for any change large or disruptive
          enough that committing it incrementally to trunk would break things for
          everyone else. Sync with trunk regularly; reintegrate when done.
        
        ## To branch or not to branch
        
        Branching has real overhead (context-switching, eventual merge effort).
        For small, quick changes that won't destabilize trunk, just commit to
        trunk directly. Reach for a branch when the change is large, long-running,
        experimental, or otherwise risky enough that isolating it is worth the
        merge-back cost later.
        
      • properties-and-metadata.md 12.2 KB
        # Properties and Metadata
        
        Deep reference for SVN's property system: versioned metadata attached to
        files, directories, and revisions, separate from file content itself.
        
        ## Table of contents
        - Property basics
        - Manipulating properties (the command set)
        - Revision properties (unversioned)
        - Properties and the normal workflow (status, diff, conflicts)
        - Automatic property setting
        - svn:ignore in depth
        - svn:externals in depth
        - Locking (svn lock/unlock, breaking/stealing, svn:needs-lock)
        - Changelists
        
        ## Property basics
        
        Properties are two-column name/value tables attached to a versioned file or
        directory (or, separately, to a whole revision). Names must be ASCII;
        values can be anything, including multi-line or binary data. Names starting
        with `svn:` are reserved for Subversion's own use — don't invent custom
        properties in that namespace.
        
        Properties are versioned exactly like file content: they're part of your
        local modifications until committed, they show up in `svn status`/`svn
        diff`, they merge and can conflict, and `svn revert` undoes property
        changes along with content changes.
        
        Custom properties are easy to *set* but hard to *search* — there's no
        indexed lookup. Finding a custom revision property means a linear walk
        across all revisions (`svn log --with-all-revprops --xml`); finding a
        custom versioned property means a recursive `svn propget` across the whole
        working copy. For metadata you'll need to search later, many teams instead
        put a structured tag in the commit log message (e.g. `Issue(s): IZ2376`)
        rather than a property.
        
        ## Manipulating properties
        
        ```bash
        svn propset copyright '(c) 2026 Example' file.php     # set from a literal value
        svn propset license -F LICENSE.txt file.php            # set from a file (multi-line/binary safe)
        svn propedit copyright file.php                        # open $EDITOR to view+edit current value
        svn proplist file.php                                  # list property NAMES on a path
        svn proplist -v file.php                                # list names AND values
        svn propget copyright file.php                          # print one property's value
        svn propdel copyright file.php                          # remove a property entirely
        ```
        Prefer `propedit` over `propset` when practical — it shows you the current
        value before you overwrite it, which matters most for revision properties
        (see below) where a mistake can't be recovered. Property commands accept
        multiple targets/globs at once: `svn propset copyright '...' src/*`.
        
        Note: setting a property to an empty string with `propset` does **not**
        delete it — `svn propdel` is the only way to actually remove one.
        
        Property names must start with a letter, `:`, or `_`; after that, digits,
        hyphens, and periods are also allowed.
        
        ## Revision properties (unversioned)
        
        Every revision automatically gets `svn:author`, `svn:date`, `svn:log`, etc.
        Unlike file/directory properties, **revision properties are not
        versioned** — editing or deleting one destroys the previous value with no
        undo. The most common legitimate use is fixing a typo in a commit message
        after the fact:
        ```bash
        svn propset svn:log "Corrected commit message." -r11 --revprop
        ```
        By default, servers disable revprop editing entirely (it requires the
        administrator to enable it via the `pre-revprop-change` hook) — see
        `repository-admin.md`.
        
        ## Properties and the normal workflow
        
        `svn status` shows `M` in the *second* column (not the first) when only
        properties changed, not content:
        ```
         M      calc/button.c
        ```
        `svn diff` shows property changes in a distinct, non-patch-compatible
        format:
        ```
        Property changes on: calc/button.c
        ___________________________________________________________________
        Added: copyright
        ## -0,0 +1 ##
        +(c) 2026 Example
        ```
        The standalone `patch` program ignores this section entirely (it only
        understands content diffs); SVN's own `svn patch` subcommand (1.7+) does
        apply property changes from a diff. One property, `svn:mergeinfo`, gets
        special human-readable formatting in diffs specifically so merge output is
        legible — treat it as managed exclusively by `svn merge`, not something to
        hand-edit.
        
        **Property conflicts** happen just like content conflicts, reported the
        same way during `svn update`, and leave a `.prej` file explaining the
        clash. Resolve with `svn resolve --accept working` after fixing the
        property value by hand.
        
        ## Automatic property setting
        
        On `svn add` / `svn import`, SVN tries to help:
        - Sets `svn:executable` automatically if the OS execute bit is set on the
          file (non-Windows filesystems).
        - Guesses `svn:mime-type` — using a configured MIME-type-mapping file if
          you've set one up, otherwise heuristics (including `libmagic` where
          available), falling back to `application/octet-stream` if it looks
          non-textual.
        
        You can also configure pattern-based auto-props in the runtime config
        (`config` file, `[auto-props]` section) so that, say, every added `*.jpg`
        automatically gets `svn:mime-type = image/jpeg`, or every `*.cpp` gets
        `svn:eol-style = native` and `svn:keywords = Id`. That `config` file lives
        at `~/.subversion/config` on Unix-like systems or
        `%APPDATA%\Subversion\config` on Windows (created the first time the `svn`
        client runs). **The auto-props section is inert by default** — you also
        have to set `enable-auto-props = yes` in the same file's `[miscellany]`
        section, or none of the patterns you configure will actually fire; this is
        the classic reason auto-props "isn't working." This has to be configured
        per-client — there's no way for a server to force these defaults onto
        everyone connecting to it (though a `pre-commit` hook can *reject* commits
        that don't set expected properties).
        
        ## svn:ignore in depth
        
        Two separate mechanisms, both pattern-based (shell-style globs: `?`, `*`,
        `[...]`):
        
        1. **`global-ignores`** runtime config option — whitespace-delimited
           patterns, applies on your machine to every working copy (e.g. editor
           backup files like `*~`).
        2. **`svn:ignore` property** — set per-directory, versioned, so it travels
           with the repository and applies to everyone who checks it out. It does
           **not** cascade to subdirectories — set it separately wherever needed.
           These patterns are *appended* to the global list, not a replacement.
        
        ```bash
        svn propedit svn:ignore calc/     # opens editor; enter one pattern per line
        svn propget svn:ignore calc/      # view current patterns
        ```
        Migrating from CVS: `svn propset svn:ignore -F .cvsignore .` imports a
        `.cvsignore` file directly (note: unlike CVS, SVN doesn't support `!` to
        reset the ignore list).
        
        Ignore patterns only affect the one-time decision of what `svn add` / `svn
        import` sweep in, and what `svn status` reports by default (`--no-ignore`
        shows ignored items with an `I` marker). Once a file is actually under
        version control, ignore patterns have **no further effect** — SVN will
        always track and commit changes to it regardless of a matching pattern.
        
        Gotcha: shell wildcards get expanded by your shell *before* SVN sees them,
        so `svn add *` bypasses `svn:ignore` for anything the shell's glob
        matches. Use `svn add --force --depth files .` for a controlled bulk-add
        that still honors ignore patterns.
        
        ## svn:externals in depth
        
        `svn:externals` maps a local subdirectory to a URL (optionally pinned to a
        revision) of another versioned directory — anyone who checks out the
        parent automatically gets the external pulled in too. Common use: vendored
        third-party code or shared assets.
        
        Modern (1.5+) syntax, one mapping per line, checkout-argument order:
        ```
        -r148 http://svn.example.com/skinproj third-party/skins
              http://svn.example.com/sounds   third-party/sounds
        ```
        Relative URL forms (1.5+) avoid hardcoding the server:
        
        | Prefix | Relative to |
        |---|---|
        | `../` | the directory the property is set on |
        | `^/` | the repository root |
        | `//` | the URL scheme of the directory the property is set on |
        | `/` | the server root |
        
        Set/edit with `propedit` (multi-line, so avoid `propset`):
        ```bash
        svn propedit svn:externals calc/
        ```
        
        **Sharp edges worth knowing before you rely on externals:**
        - Pin to an explicit revision unless you deliberately want externals to
          float to `HEAD` — otherwise everyone's checkout silently picks up
          upstream changes you don't control, and backdating your working copy
          won't correctly backdate an unpinned external.
        - External working copies are genuinely separate, disjoint working
          copies. Committing in the parent working copy does **not** recurse into
          externals — you must `svn commit` inside the external directory itself.
        - Absolute URLs in old-style definitions break if you move/rename the
          directory the property lives on, or if you `svn relocate` the parent —
          externals don't automatically follow either.
        - File externals (single-file, not directory) can't be moved or deleted
          directly (edit the `svn:externals` property instead), and can only
          reference a file in the *same* repository.
        - `--ignore-externals` disables external processing for `checkout`,
          `update`, `switch`, `export`, and `status` when you need to skip them.
        
        ## Locking
        
        Locking exists for the one case copy-modify-merge handles badly:
        essentially-unmergeable files (binary images, fonts, compiled assets).
        SVN's lock is one of **three unrelated things called "lock" in SVN** —
        don't confuse them:
        1. **Repository locks** (this section) — mutual exclusion between users.
        2. **Working copy locks** — internal, prevents two SVN client processes
           from stepping on the same working copy; shown as `L` in `svn status`;
           cleared with `svn cleanup` (see `troubleshooting.md`).
        3. **Database locks** — internal to the Berkeley DB backend; can "wedge" a
           repository if a process dies mid-transaction (see `repository-admin.md`).
        
        ```bash
        svn lock file.jpg -m "Editing for tomorrow's release."   # claim exclusive right to commit
        svn status                # shows K (locKed) next to the file
        svn info file.jpg          # shows lock token, owner, comment, timestamp
        svn unlock file.jpg        # release voluntarily
        ```
        While locked by someone else, your commits touching that file are rejected
        until they unlock or you break the lock. `svn status -u` shows an `O`
        (locked by Other) next to affected files.
        
        **Breaking vs. stealing:** by default anyone can release anyone else's
        lock (not just the owner or an admin) — this is a deliberate "locks are a
        communication tool, not a security boundary" design choice, though
        `pre-lock`/`pre-unlock` hooks can enforce stricter policy if a team wants
        it.
        ```bash
        svn unlock --force https://example.com/repo/file.jpg    # break someone else's lock
        svn lock --force file.jpg                                # steal: break + relock atomically
        ```
        An admin can also inspect/remove locks directly on the server without a
        working copy: `svnadmin lslocks REPO_PATH`, `svnadmin rmlocks REPO_PATH
        PATH`.
        
        By default, **committing releases every lock your commit touches — even
        ones on files you didn't actually change** if they were part of the
        targets you passed to `svn commit`. Use `--no-unlock` on the commit to keep
        holding locks you still need.
        
        **`svn:needs-lock`** is a property, not a lock itself — attach it to a
        file (any value; only presence matters) and SVN makes the file read-only
        on checkout/update until someone locks it, then read/write while locked.
        It's a *reminder* mechanism (many editors refuse to save a read-only file,
        prompting the user to go lock it first) — it doesn't stop anyone who edits
        around the read-only bit, and it doesn't make the repository require a
        lock to accept a commit.
        
        ## Changelists
        
        A lightweight, purely local grouping mechanism — not synced to the
        repository — for organizing pending edits into named buckets so you can
        `commit`/`diff`/`revert` a subset of your working copy at once:
        ```bash
        svn changelist my-bugfix file1.php file2.php
        svn commit --changelist my-bugfix -m "Fix the thing"
        ```
        Useful when you have several unrelated edits mixed together in one working
        copy and want to commit (or review) them separately without juggling
        multiple checkouts. Two limits worth knowing before relying on them:
        changelists only apply to files, not directories, and a file can only
        belong to one changelist at a time (assigning it to a new one silently
        moves it out of the old one). Also, **committing a file normally clears
        its changelist assignment** once the commit succeeds — pass
        `--keep-changelists` to the commit if you want the label to stick around
        for next time.
        
      • repository-admin.md 8 KB
        # Repository Administration
        
        Deep reference for creating, configuring, and serving SVN repositories.
        Most of this is server-side/admin territory rather than day-to-day client
        use — read `svn.md` first if you just need to *use* an existing
        repository.
        
        ## Table of contents
        - Creating a repository
        - Repository layout for multiple projects
        - Hooks
        - The svnadmin / svnlook toolkit
        - Choosing a server: svnserve vs. SSH vs. Apache
        - Backup and disaster recovery basics
        
        ## Creating a repository
        
        ```bash
        svnadmin create /var/svn/myrepo                  # default backend (FSFS)
        svnadmin create --fs-type fsfs /var/svn/myrepo    # explicit FSFS
        svnadmin create --fs-type bdb  /var/svn/myrepo    # Berkeley DB backend (legacy; FSFS is standard now)
        ```
        `svnadmin` and `svnlook` are **server-side tools** — they take a local
        filesystem path, not a URL, and can't operate across a network. (Passing a
        URL, even a `file://` one, to `svnadmin` is a very common beginner
        mistake.) The regular `svn` client is what takes URLs.
        
        A fresh repository starts at revision 0 (an empty root directory) with a
        single revision property, `svn:date`, set to creation time.
        
        Don't hand-edit anything under a repository's data store directly —
        `svnadmin` is the supported interface for everything short of examining
        hook scripts and config files, which are meant to be edited directly.
        
        ## Repository layout for multiple projects
        
        If one repository hosts several unrelated projects, give each its own
        project root containing its own `trunk`/`branches`/`tags`, rather than one
        shared set at the repository root:
        ```
        svn list file:///var/svn/multi-project-repo
        project-A/
        project-B/
        svn list file:///var/svn/multi-project-repo/project-A
        trunk/
        branches/
        tags/
        ```
        If the repository holds exactly one project, the repository root can
        double as the project root (a bare `trunk/branches/tags` at the top).
        
        ## Hooks
        
        A hook is an executable script the repository runs automatically when a
        specific event happens. "Pre" hooks run before an operation and can reject
        it (non-zero exit); "post" hooks run after and are read-only with respect
        to the operation itself (useful for notifications, not enforcement).
        
        Available hooks: `start-commit`, `pre-commit`, `post-commit`,
        `pre-revprop-change`, `post-revprop-change`, `pre-lock`, `post-lock`,
        `pre-unlock`, `post-unlock`.
        
        Templates are dropped into `myrepo/hooks/` automatically at creation:
        ```bash
        ls myrepo/hooks/
        post-commit.tmpl  post-unlock.tmpl  pre-revprop-change.tmpl
        post-lock.tmpl    pre-commit.tmpl   pre-unlock.tmpl
        post-revprop-change.tmpl  start-commit.tmpl
        ```
        To activate one on Unix: copy the `.tmpl` file to the same name **without**
        the extension, edit it, and make it executable. On Windows, the file
        extension itself must indicate an executable type (`.exe`, `.bat`, etc.) —
        Windows doesn't use the Unix executable bit.
        
        Critical gotchas:
        - Hooks run with an **empty environment** — no `$PATH`, nothing. Use
          absolute paths to every program the hook calls, and set any environment
          variables the hook needs explicitly.
        - Hooks execute as whatever OS user runs the server process (typically the
          service account for Apache/svnserve), so they need OS-level permission
          to do whatever they do, including reading the repository itself.
        - **Never modify a commit's contents from a hook.** It's tempting to
          auto-fix style/policy violations in a `pre-commit` hook, but this
          desyncs the client's cached view of what it just committed from what's
          actually in the repository, causing confusing, hard-to-diagnose
          problems. Validate and reject non-compliant commits instead of silently
          rewriting them.
        
        The most common use of `pre-revprop-change` specifically is enabling
        revision-property edits at all — by default, editing an unversioned
        revprop (e.g. fixing a typo in a commit message after the fact) is
        disabled server-side until this hook exists and permits it, precisely
        because revprop changes are not versioned and can't be undone.
        
        ## The svnadmin / svnlook toolkit
        
        Both are server-side-only (local paths, not URLs):
        
        - **`svnadmin`** — repository creation, maintenance, migration:
          `create`, `dump`/`load` (export/import for migration or backup),
          `hotcopy` (safe live backup), `verify`, `recover` (fix a wedged BDB
          repository), `pack` (FSFS space optimization), `setlog`/`setrevprop`
          (revprop edits from the server side, bypassing hooks), `lslocks`/
          `rmlocks`.
        - **`svnlook`** — read-only inspection of a repository's current or
          pending (in-transaction) state: `svnlook log`, `svnlook changed`,
          `svnlook diff`, `svnlook tree`, `svnlook author`. Commonly called from
          inside hook scripts to inspect the commit that's in flight.
        - **`svndumpfilter`** — include/exclude specific paths when replaying a
          dump file; the practical way to permanently strip something
          sensitive out of history (SVN has no "delete this from history" command
          otherwise, by design — see `branching-tagging-merging.md`'s note on
          undoing commits).
        - **`svnsync`** — one-way mirroring of a repository (full or partial) to
          another location, revision property included.
        - **`svnrdump`** — dump/load over the network for a *remote* repository
          you don't have filesystem access to (`svnadmin dump`/`load` require
          local access).
        
        ## Choosing a server: svnserve vs. SSH vs. Apache
        
        No universally "best" option — trade-offs only:
        
        **Plain `svnserve`** — fastest to set up, stateful protocol (faster than
        WebDAV), no OS accounts needed, password never sent over the network.
        Downside: traffic unencrypted by default and passwords stored in clear
        text server-side unless you additionally configure SASL; minimal logging;
        no built-in web browsing of the repo.
        
        **`svnserve` over SSH** — reuses existing SSH accounts/infrastructure,
        still fast (stateful protocol), fully encrypted. Downside: only one auth
        method, minimal logging, requires shared system group or SSH key
        management, easy to misconfigure file permissions.
        
        **Apache + `mod_dav_svn`** — integrates with any Apache auth scheme, full
        Apache logging, SSL, works through corporate firewalls (it's just HTTP/S),
        built-in repository browsing, can be mounted as a WebDAV network drive.
        Downside: noticeably slower than `svnserve` (stateless protocol, more
        round-trips), more complex initial setup.
        
        **Rule of thumb from the SVN Book's authors:** start with plain
        `svnserve` for a small team getting going — least setup, fewest moving
        parts — and move to Apache later only if you specifically need its
        integration or web-browsing features. If you need to plug into existing
        identity infrastructure (LDAP, Active Directory, X.509), you need either
        Apache or `svnserve` with SASL. **Avoid exposing repositories directly via
        `file://` to multiple users** (or the equivalent `svn+ssh://` local-account
        pattern) — it removes every layer of access control between users and the
        raw repository data.
        
        Regardless of which you pick: run the server process as a single dedicated
        `svn` OS user, and make that user the sole owner of the repository
        directory.
        
        Deliberately out of scope here: the actual configuration syntax once
        you've picked one (`svnserve.conf` and `authz`/`passwd` file formats for
        svnserve, or the Apache `httpd.conf`/`mod_dav_svn` directives and
        `AuthzSVNAccessFile` setup for Apache). That's a bigger, more
        install-specific topic than fits a "which one and why" overview — the SVN
        Book's own Server Configuration chapter is the place for it if you need
        the exact directives.
        
        ## Backup and disaster recovery basics
        
        - **`svnadmin hotcopy SRC DST`** — safe, consistent live backup while the
          repository is in use.
        - **`svnadmin dump`** / **`svnadmin load`** — portable, human-inspectable
          backup/migration format; also how you migrate between FSFS and BDB
          backends, or between major Subversion versions when a direct upgrade
          isn't supported.
        - **`svnadmin verify`** — integrity check.
        - **`svnadmin recover`** — fixes a BDB repository "wedged" by a crashed
          process holding a database lock (see the "three meanings of lock" note
          in `properties-and-metadata.md`). Not applicable to FSFS, which doesn't
          have this failure mode.
        
      • troubleshooting.md 8.3 KB
        # Troubleshooting
        
        Deep reference for conflict resolution, mixed-revision confusion, and
        recovering a stuck working copy.
        
        ## Table of contents
        - Full conflict-resolution walkthrough
        - Merging conflict markers by hand
        - Mixed-revision working copies explained
        - Tree conflicts (structural conflicts)
        - svn cleanup and recovering from interruptions
        - Quick error-message lookup
        
        ## Full conflict-resolution walkthrough
        
        Conflicts surface during `svn update` (or `svn merge`, or `svn switch`)
        when incoming changes overlap your uncommitted local edits. SVN stops and
        asks interactively:
        ```
        Conflict discovered in 'bar.c'.
        Select: (p) postpone, (df) diff-full, (e) edit,
                (mc) mine-conflict, (tc) theirs-conflict,
                (s) show all options:
        ```
        Full option set (via `s`):
        
        | Key | Action |
        |---|---|
        | `e` (edit) | Open the file with conflict markers in `$EDITOR` |
        | `df` (diff-full) | Show all local changes plus the conflicting region |
        | `r` (resolved) | After manually fixing a file, tell SVN it's resolved |
        | `dc` (display-conflict) | Show only the conflicting regions, not the whole diff |
        | `mc` (mine-conflict) | Your version wins *only* where there's an actual conflict; non-conflicting incoming changes still merge in |
        | `tc` (theirs-conflict) | Their version wins only where conflicting; your non-conflicting changes are kept |
        | `mf` (mine-full) | Discard the entire incoming update for this file; keep your whole file as-is |
        | `tf` (theirs-full) | Discard all your local changes to this file; take the server's version entirely |
        | `p` (postpone) | Leave it conflicted, deal with it after the update finishes |
        | `l` (launch) | Open a configured external merge tool |
        | `s` (show all) | Print this menu |
        
        `mc`/`tc` vs `mf`/`tf` is the distinction that trips people up: the
        `*-conflict` pair only overrides the specifically-overlapping lines and
        still merges everything else cleanly; the `*-full` pair throws away one
        side's changes to the file **entirely**, conflicting or not.
        
        Postponing (`p`, or pre-committing to it with `svn update
        --non-interactive`, which auto-marks every conflict `C` without prompting)
        leaves three extra unversioned files next to the conflicted one:
        - `filename.mine` — your working copy version right before the update
          (omitted if SVN considers the file unmergeable/binary)
        - `filename.rOLDREV` — the file as it was at your last-synced (`BASE`)
          revision
        - `filename.rNEWREV` — the version just received from the server
        
        **SVN will refuse to commit a file with these leftover files present.**
        Resolve first:
        ```bash
        svn resolve --accept base file.c          # revert to the pre-edit checked-out version
        svn resolve --accept mine-full file.c     # keep only your version
        svn resolve --accept theirs-full file.c   # keep only the server's version
        svn resolve --accept working file.c       # accept whatever you hand-edited the file to (see below)
        ```
        `svn resolve` requires explicit filenames — it won't guess which
        conflicted file(s) you mean.
        
        ## Merging conflict markers by hand
        
        If you edited the file directly to resolve a conflict (option `e`, or just
        opening it in your own editor), it'll contain markers like:
        ```
        <<<<<<< .mine
        Salami
        Mortadella
        =======
        Sauerkraut
        Grilled Chicken
        >>>>>>> .r2
        ```
        Everything between `<<<<<<< .mine` and `=======` is your version; between
        `=======` and `>>>>>>> .rN` is the incoming version. Edit the file down to
        what it *should* say, delete the marker lines entirely, save, then:
        ```bash
        svn resolve --accept working file.c
        svn commit -m "..."
        ```
        Forgetting to remove the marker lines themselves is the classic mistake —
        SVN will happily commit a file that still contains literal `<<<<<<<` text
        once you've told it the conflict is resolved.
        
        ## Mixed-revision working copies explained
        
        Normal, not a bug: after a commit, only the files/directories you actually
        touched get bumped to the new revision number. The rest of the working
        copy stays at whatever revision it was already at. Run `svn status -v` to
        see the actual mixture — the second column is each item's individual
        working revision.
        
        Why this matters practically:
        - **A successful commit can look like a failed one.** Immediately after
          `svn commit` returns `Committed revision N`, the working-copy *root* is
          usually still at some revision **lower than N** — only the paths you
          touched were bumped. So `svn info --show-item revision` reads "behind"
          and it is easy to conclude the push didn't land. It did. Confirm against
          the repository, not the working copy:
          `svn log -l 1 <REPO_URL>` (or `svn list <REPO_URL>/tags/`), then run
          `svn update` to bring the whole working copy to one revision.
        - You **cannot** delete a file/directory that isn't fully up to date —
          SVN blocks it to avoid destroying changes you haven't seen yet.
        - You **cannot** commit a property change to an out-of-date directory, for
          the same reason.
        - As of 1.7, **`svn merge` refuses to target a mixed-revision working
          copy** by default (`--allow-mixed-revisions` overrides this, but only do
          so if you understand the consequences — merges into mixed-revision
          copies can produce spurious conflicts).
        - `svn log` on a working-copy path can show a truncated or seemingly
          "wrong" history if that path's local working revision is older than you
          expect — it reports history *as of the working revision*, not `HEAD`.
        
        Mixed revisions are also genuinely useful — deliberately backdating part
        of a working copy to test an older snapshot of a subdirectory, for
        instance — so the goal isn't to avoid them entirely, just to know a clean
        `svn update` at the top of the working copy is what gets you back to a
        single, uniform revision when an operation requires one.
        
        ## Tree conflicts (structural conflicts)
        
        Distinct from content/text conflicts: a tree conflict happens when an
        update or merge can't reconcile a *structural* change — e.g. someone
        deleted or moved a file on the server while you also had it locally
        modified, or renamed, or deleted. SVN can't guess intent here (was the
        file supposed to still exist or not?), so it flags a tree conflict rather
        than guessing.
        
        ```bash
        svn status      # tree-conflicted items show a C in status too
        svn info file   # shows a description of the specific tree conflict
        ```
        Resolution generally means deciding by hand which side's structural intent
        should win (keep your local move, or accept the server's deletion, etc.),
        then `svn resolve` once the working copy reflects your decision. There's
        less blanket "accept mine/theirs" automation here than for text conflicts
        because the possible structural combinations vary so much — check `svn
        info` on the conflicted path for specifics before deciding.
        
        ## svn cleanup and recovering from interruptions
        
        If an `svn` command gets interrupted (killed, crashes, loses network
        mid-operation), the working copy can be left administratively locked —
        shown as an `L` in `svn status` (this is a *working-copy* lock, one of
        three unrelated things SVN calls a "lock"; see
        `properties-and-metadata.md` for the other two). Clear it with:
        ```bash
        svn cleanup
        ```
        Run this from the top of the affected working copy. It also removes any
        leftover temporary files from the interrupted operation. If a large
        operation keeps getting interrupted before it can finish, SVN 1.7+
        generally allows safely resuming — re-run the same command rather than
        starting over.
        
        ## Quick error-message lookup
        
        | Message (paraphrased) | What's actually going on |
        |---|---|
        | `Commit failed... File '...' is out of date` | Someone else committed to that file since your last update |
        | `svn: E155011` | The precise code behind that "out of date" message |
        | `Aborting commit: '...' remains in conflict` | Leftover `.mine`/`.rOLDREV`/`.rNEWREV` files — resolve before committing |
        | `svn: E155015` | The precise code behind that "remains in conflict" message |
        | `Cannot merge into mixed-revision working copy` | Run `svn update` first |
        | `svn: E195020` | The precise code behind that mixed-revision merge block |
        | `svn: warning: W160035: Path '...' is already locked by user 'X'` | Someone holds an exclusive `svn lock`; ask them or use `--force` to break/steal it |
        | `The subversion command line tools are no longer provided by Xcode` | macOS-specific — Apple dropped `svn` from Xcode Command Line Tools years ago; install via Homebrew (`brew install subversion`) instead |
        | `svn: E155036` (working copy too old) | Working copy format predates the client version; run `svn upgrade` in it |
        
    • svn.md 15.5 KB
      # Subversion (SVN)
      
      Practical reference for working with Apache Subversion, distilled from
      *Version Control with Subversion* by Ben Collins-Sussman, Brian W.
      Fitzpatrick, and C. Michael Pilato (the canonical "SVN Book", licensed
      under CC BY 2.0 — https://creativecommons.org/licenses/by/2.0/ —
      source at https://svnbook.red-bean.com/) — specifically its 1.7 edition,
      the version linked when this skill was built. Command behavior described
      here is accurate for any modern client (1.14.x and similar as of this
      writing) unless a note says otherwise; SVN's core workflow has changed
      very little since 1.7, with the occasional exception called out inline
      (e.g. `--reintegrate` becoming automatic in 1.8+). This file covers what
      comes up in day-to-day use. The `references/` folder goes deeper on
      branching/merging, properties, repository administration, and
      troubleshooting — read the relevant one when the task needs it.
      
      ## Mental model
      
      Get this part right and almost everything else about SVN stops being
      surprising:
      
      - **Repository vs. working copy.** The repository is the single source of
        truth, living on a server (or in a local directory it doesn't matter
        which). A working copy is a local checkout you actually edit. Nothing you
        do locally affects anyone else until you `commit`; nothing anyone else
        does affects you until you `update`. Push and pull are always separate
        actions — committing never pulls in others' changes, and updating never
        publishes yours.
      - **Revisions are global, not per-file.** Every commit — no matter how many
        files it touches — creates exactly one new revision number for the
        *entire repository tree*. "Revision 5 of foo.c" really means "foo.c as it
        appears in the repository snapshot taken at revision 5"; foo.c may not
        have changed at all between revisions 4 and 5. This is the single biggest
        mental adjustment for people coming from per-file systems.
      - **Working copies are often mixed-revision.** After you commit, only the
        files you touched get bumped to the new revision number; the rest of your
        working copy stays where it was. This is normal, not a bug — `svn status
        -v` shows you the mixture. It does mean some operations (merge targets,
        reintegration) require an up-to-date, single-revision working copy first.
      - **Copy-modify-merge, not lock-modify-unlock.** By default SVN lets
        everyone edit their own working copy freely and reconciles changes at
        commit time (conflicts are rare and usually easy). True exclusive locking
        exists but is meant only for genuinely unmergeable files like binary
        images — see `references/properties-and-metadata.md`.
      - **Branches and tags are just directories.** SVN has no branch object.
        A "branch" is a directory created with a cheap, constant-time server-side
        `svn copy`; it's a branch only because people agree to treat it that way.
        A "tag" is exactly the same mechanism, used for a copy nobody is supposed
        to commit to. This is why the trunk/branches/tags layout convention
        (below) exists — SVN itself enforces none of it.
      
      ## If you know Git
      
      SVN predates Git and works differently in ways that trip people up:
      
      | Git | SVN |
      |---|---|
      | `git clone` | `svn checkout` (`svn co`) |
      | `git add` (stage) + `git commit` (local) | `svn add` only schedules; there's no local commit — `svn commit` goes straight to the shared repository |
      | `git commit` then `git push` | `svn commit` (one step, always "pushes") |
      | `git pull` / `git fetch` + merge | `svn update` (`svn up`) |
      | `git branch` (cheap, local, instant) | `svn copy trunk branches/name` (cheap, but it's a *server round-trip commit*, not local and instant) |
      | `git merge` | `svn merge` — but you must be in the *target* working copy and run it explicitly; there's no "current branch" |
      | `git log` | `svn log` (no `--oneline` by default; use `-q`) |
      | `git diff` (unstaged) | `svn diff` (compares working copy to the pristine checked-out copy) |
      | SHA hashes | Sequential global revision numbers (r1, r2, r3…) |
      | `.gitignore` | `svn:ignore` property (per-directory, not inherited to subdirectories) + the `global-ignores` runtime config option |
      | `git stash` | No real equivalent; closest is `svn diff > patch.txt` then `svn revert`, or a changelist (see `references/properties-and-metadata.md`) |
      | Cheap local experimentation, rewrite history freely | Nothing is ever truly deleted or rewritten; history is append-only |
      | Distributed, no "the" server | Centralized — one authoritative repository |
      
      The practical upshot: **commits in SVN are immediately public.** There's no
      staging area and no local-only commit, so don't reach for `svn commit` the
      way you'd reach for `git commit` — it's closer to `git push`. Use `svn
      status` and `svn diff` (no network needed) as your staging-area substitute
      to review before you commit.
      
      ## The daily work cycle
      
      This is the loop you'll use constantly. It's always some ordering of:
      update → change → review → fix mistakes → resolve conflicts → commit.
      
      **Get a working copy:**
      ```bash
      svn checkout https://example.com/repo/trunk my-project   # full history download
      svn export https://example.com/repo/trunk my-project     # no .svn metadata, for a clean release bundle
      ```
      
      **Bring your working copy up to date:**
      ```bash
      svn update          # svn up — pulls latest changes, merges into your working copy
      svn update -r 1729  # backdate to a specific revision (whole WC or a path)
      ```
      
      **Make changes.** Just edit files normally. For structural changes, tell SVN
      explicitly:
      ```bash
      svn add newfile.php          # schedule for addition (recursive if it's a directory)
      svn add --force .            # bulk-add everything not ignored, without re-adding what's tracked
      svn delete oldfile.php       # schedule for deletion (deletes locally now, removed from repo on commit)
      svn copy foo.php bar.php     # duplicate with history retained
      svn move old.php new.php     # rename; same as copy + delete
      svn mkdir newdir             # create + schedule for addition
      ```
      None of these touch the repository until you commit.
      
      **Review before committing** (all fully offline — no network needed):
      ```bash
      svn status           # svn st — overview: ? untracked, A added, D deleted, M modified, C conflicted
      svn status -v        # long form — also shows working revision per item (reveals mixed revisions)
      svn status -u        # -u contacts the repo, flags items that are out of date (asterisk marker)
      svn diff             # line-level unified diff of local changes
      svn diff -r 3        # diff working copy against a specific revision
      svn diff -r 2:3      # diff two repository revisions directly
      ```
      
      **Fix mistakes:**
      ```bash
      svn revert file.php      # discard local changes, restore pristine version
      svn revert -R .          # recursive revert of an entire directory tree
      ```
      `svn revert` undoes *any* scheduled operation — an accidental `add`, `delete`,
      or edit — not just content changes.
      
      **Resolve conflicts.** You'll see this during `svn update` when someone
      else's changes overlap yours:
      ```
      Conflict discovered in 'bar.c'.
      Select: (p) postpone, (df) diff-full, (e) edit,
              (mc) mine-conflict, (tc) theirs-conflict,
              (s) show all options:
      ```
      Fastest paths: `p` to postpone and deal with it after the update finishes, or
      pick a whole-file winner immediately with `svn resolve --accept mine-full
      file` / `--accept theirs-full file`. For everything in between (editing conflict
      markers by hand, partial accepts, non-interactive updates), see
      `references/troubleshooting.md`.
      
      **Commit:**
      ```bash
      svn commit -m "Fix login redirect bug"     # svn ci
      svn commit -F commit-message.txt           # message from a file
      ```
      If a commit is rejected as "out of date," someone beat you to it — run `svn
      update`, resolve anything that conflicts, and commit again. SVN will never
      silently overwrite someone else's work.
      
      ## Examining history
      
      ```bash
      svn log                        # reverse-chronological log for the current path
      svn log -r 5:19                # revisions 5 through 19, chronological
      svn log -r 19:5                # same range, reverse order
      svn log -v                     # -v also lists every changed path per revision
      svn log --diff                 # append a unified diff to each log entry (1.7+)
      svn info                       # URL, revision, last-changed author/date for a path -- the quickest single-item lookup
      svn cat -r 12 file.php          # print a file as it existed at revision 12
      svn cat -r 12 file.php > old.php   # ...and save it
      svn blame file.php              # a.k.a. svn annotate / svn praise — line-by-line last-changed attribution
      svn list https://example.com/repo/trunk    # directory listing without checking anything out
      ```
      Gotcha: `svn log` with no arguments right after a commit often *won't* show
      the commit you just made — the parent directory's working revision usually
      lags behind the file you touched. Run `svn update` first, or pass `-r`
      explicitly.
      
      ## Repository layout: trunk / branches / tags
      
      SVN imposes no structure — this convention is universal purely because
      practically every project and every SVN-based platform (including the
      WordPress.org plugin and theme directories) expects it:
      
      ```
      project-root/
      ├── trunk/       # the main line of development
      ├── branches/    # divergent lines of development
      └── tags/        # named, frozen snapshots — nobody commits here
      ```
      
      Getting an existing unversioned tree into a fresh repository:
      ```bash
      svn import /path/to/local/project https://example.com/repo/project/trunk -m "Initial import"
      ```
      Note `svn import` commits directly — it does **not** turn your local folder
      into a working copy afterward. Check out a fresh working copy separately if
      you want to keep working on it.
      
      **Creating a branch or tag is the same operation** — a server-side `svn
      copy`, which is a cheap, near-instant commit that doesn't duplicate any
      data:
      ```bash
      svn copy https://example.com/repo/trunk https://example.com/repo/branches/my-feature \
        -m "Start my-feature branch"
      
      svn copy https://example.com/repo/trunk https://example.com/repo/tags/1.2.0 \
        -m "Tag release 1.2.0"
      ```
      The only difference between a branch and a tag is social convention: a tag
      is a copy everyone agrees not to commit to. If someone does commit to it, it
      has effectively become a branch. Full depth on branching, syncing, and
      merging is in `references/branching-tagging-merging.md` — read it before
      doing anything beyond a simple tag, especially before a `--reintegrate`
      merge.
      
      ## Properties, in brief
      
      Properties are versioned metadata (name/value pairs) attached to files,
      directories, or revisions — separate from file content. The ones worth
      knowing immediately:
      
      - `svn:ignore` — per-directory list of filename patterns to hide from `svn
        status` / skip on `svn add --force`. Does **not** cascade to
        subdirectories (unlike `.gitignore`). Set it with `svn propedit svn:ignore
        path/` (multi-line values need an editor, not `propset`).
      - `svn:mime-type` / `svn:executable` / `svn:eol-style` — file portability
        properties; auto-detected on `add`/`import` but worth checking on binary
        assets and shell scripts.
      - `svn:externals` — pulls another repository path into a subdirectory
        automatically for everyone who checks out. Useful for vendored
        dependencies, with real sharp edges (see reference file).
      - `svn:needs-lock` — makes SVN mark a file read-only until someone runs `svn
        lock` on it; the practical way to signal "this file can't be merged,
        please take turns" for binary assets.
      
      Full command syntax (`propset`, `propedit`, `propget`, `proplist`,
      `propdel`, revision properties) and gotchas are in
      `references/properties-and-metadata.md`.
      
      ## Repository administration, in brief
      
      ```bash
      svnadmin create /var/svn/myrepo          # create a repository (FSFS backend by default)
      ```
      Hooks (`pre-commit`, `post-commit`, `start-commit`, `pre-revprop-change`,
      etc.) live as executable scripts in `myrepo/hooks/` — templates are dropped
      there automatically at creation. Choosing between `svnserve`, `svnserve`
      over SSH, and Apache/mod_dav_svn, plus `svnadmin`/`svnlook` toolkit basics
      and backup, are in `references/repository-admin.md`.
      
      ## Common errors and what they mean
      
      | Message | Meaning | Fix |
      |---|---|---|
      | `Commit failed... File '...' is out of date` | Someone else committed since your last update | `svn update`, resolve, retry commit |
      | `E155011` | The exact code behind the "out of date" message above | Same fix — update, resolve, retry |
      | `Aborting commit: '...' remains in conflict` | A conflicted file has leftover `.mine`/`.rOLDREV`/`.rNEWREV` files | Resolve the conflict with `svn resolve`, then commit |
      | `E155015` | The exact code behind the "remains in conflict" message above | Same fix — resolve, then commit |
      | `Cannot merge into mixed-revision working copy` | Your working copy has multiple revisions in it | `svn update` first, then merge |
      | `svn: warning: W160035: Path '...' is already locked by user 'X'` | Someone holds an exclusive lock | Ask them, or `svn unlock --force` / `svn lock --force` to break/steal it (see properties reference) |
      
      More detail, plus mixed-revision quirks and `svn cleanup`, is in
      `references/troubleshooting.md`.
      
      ## Worked example: publishing to a WordPress.org-style plugin/theme SVN
      
      WordPress.org's plugin and theme directories run on exactly the
      trunk/branches/tags/assets convention described above, so the general
      workflow applies directly:
      
      ```bash
      svn co https://plugins.svn.wordpress.org/your-plugin-slug
      cd your-plugin-slug
      # copy release-ready files into trunk/ (make sure vendored/composer
      # dependencies are included -- there's no build step run on the server)
      svn add trunk/*
      svn commit -m "Initial release"
      
      # tag the release so it's what actually gets served to users
      svn copy trunk tags/1.0.0
      svn commit -m "Tag 1.0.0"
      ```
      Two platform-specific gotchas that aren't in the SVN Book itself but matter
      a lot here: the `Stable tag` field in `readme.txt` must exactly match the
      tag folder you create, or the directory serves the wrong version; and the
      `assets/` folder (screenshots, icons, banners) sits as a sibling of `trunk`
      and `tags` at the repository root, not inside either of them.
      
      ## Reference files
      
      - `references/branching-tagging-merging.md` — creating and syncing
        branches, moving a working copy between branches with `svn switch`,
        `svn merge` mechanics, `--reintegrate`, mergeinfo, cherry-picking,
        undoing a bad commit, resurrecting deleted files, common branching
        patterns (release branches, feature branches), when *not* to branch.
      - `references/properties-and-metadata.md` — full property command syntax,
        automatic property setting, `svn:ignore` in depth, `svn:externals` formats
        and pitfalls, the locking feature end to end (`svn lock`/`unlock`,
        breaking/stealing locks, `svn:needs-lock`), changelists.
      - `references/repository-admin.md` — creating and administering
        repositories, writing hooks, the `svnadmin`/`svnlook` toolkit, choosing
        `svnserve` vs. `svnserve`-over-SSH vs. Apache/`mod_dav_svn`, backup and
        replication basics. (Choosing a server is covered; writing that server's
        detailed config files is explicitly out of scope — see the note inside.)
      - `references/troubleshooting.md` — full conflict-resolution walkthrough
        (interactive options, merging conflict markers by hand, non-interactive
        mode), mixed-revision working copies explained, tree conflicts, `svn
        cleanup` and recovering from interruptions, the three different meanings
        of "lock" in SVN.
      
      Read the relevant reference file in full before attempting anything beyond
      the basics covered above — each goes considerably deeper than this
      overview and includes the exact command sequences and warnings from the
      source material.
      
  • debugging.md 19.3 KB
    # Debugging — WordPress & Elementor
    
    > **When to read this file:** Diagnosing a bug, a white screen, a failed wp.org check, a CSS/JS
    > change that "didn't come through," or an editor preview that broke. Work the layers in order:
    > **static analysis first** (cheapest — catches it before it runs), then **runtime logging**,
    > then **Elementor-specific** state, then **browser** (JS/CSS). The symptom→cause table at the
    > bottom maps real failures to where to look.
    
    > ⚠️ **`php -l` / `node --check` prove SYNTAX ONLY.** They do **not** catch a typed-signature
    > mismatch (a fatal — see `field-notes.md` §1), a missing/ineffective control, a leaked filter,
    > escaping issues, or any runtime behaviour. Passing lint means nothing about correctness.
    
    ---
    
    ## 1. Static analysis (catch it before it runs)
    
    Three complementary tools — each catches a different class of bug:
    
    | Tool | Catches | Does NOT catch |
    |---|---|---|
    | **PHPCS + WPCS** | Coding standards, **security** (`WordPress.Security.*`: unescaped output, unsanitized input, missing nonces), naming/prefix, i18n | Type errors, logic bugs, runtime behaviour |
    | **PHPStan** (WordPress) | **Type & logic** bugs — wrong arg types, impossible conditions, dead code, null misuse, undefined methods | Coding-style / escaping policy |
    | **Plugin Check 2.1.0** | The **review-grade superset** — bundles PHPCS+WPCS plus repo/guideline/perf/a11y/i18n checks (what wp.org reviewers run); 2.1.0 adds an SVN-repo scan, a PHP-error-reporting check, and per-dependency `Requires Plugins` validation | Guarantees nothing about approval (human review is still mandatory) |
    
    ### PHPCS + WPCS — WordPress Coding Standards
    
    PHPCS is the engine; **WPCS** (WordPress Coding Standards) is the ruleset that emits the
    `WordPress.Security.*`, `WordPress.NamingConventions.*`, `WordPress.WP.I18n`, etc. codes.
    
    ```bash
    # Install per-project (WPCS 3.x pulls PHPCSUtils + PHPCSExtra automatically;
    # the composer-installer auto-registers installed_paths so phpcs finds WPCS):
    composer require --dev \
      wp-coding-standards/wpcs:"^3.1" \
      dealerdirect/phpcodesniffer-composer-installer:"^1.0"
    
    # Run (uses phpcs.xml.dist below); phpcbf auto-fixes what is mechanically fixable:
    ./vendor/bin/phpcs
    ./vendor/bin/phpcbf
    ```
    
    Drive it with a committed **`phpcs.xml.dist`** ruleset — this is where you set your text domain,
    prefixes, and minimum WP version so the security/naming/i18n sniffs actually apply:
    
    ```xml
    <?xml version="1.0"?>
    <ruleset name="MyPlugin">
        <description>WPCS ruleset for my plugin.</description>
    
        <file>.</file>
        <exclude-pattern>*/vendor/*</exclude-pattern>
        <exclude-pattern>*/node_modules/*</exclude-pattern>
        <exclude-pattern>*/includes/lib/*</exclude-pattern>   <!-- vendored libs: don't lint, disclose -->
    
        <arg value="ps"/>                          <!-- p = progress, s = show sniff codes -->
        <arg name="extensions" value="php"/>
        <arg name="parallel" value="8"/>
    
        <rule ref="WordPress"/>                     <!-- WordPress-Core + Extra + Docs + security -->
    
        <config name="minimum_wp_version" value="7.0"/>
    
        <!-- i18n: every text-domain must match the slug (else WordPress.WP.I18n flags it) -->
        <rule ref="WordPress.WP.I18n">
            <properties>
                <property name="text_domain" type="array">
                    <element value="myplugin"/>
                </property>
            </properties>
        </rule>
    
        <!-- Prefix sniff: declare your prefixes or every global symbol is flagged -->
        <rule ref="WordPress.NamingConventions.PrefixAllGlobals">
            <properties>
                <property name="prefixes" type="array">
                    <element value="myplugin"/>
                    <element value="MyPlugin"/>
                    <element value="MYPLUGIN"/>
                </property>
            </properties>
        </rule>
    </ruleset>
    ```
    
    > ⚠️ **PHPCS `--standard=WordPress` surfaces MORE than Plugin Check does.** PC runs a curated
    > **review** ruleset; the full `WordPress` standard adds `WordPress-Extra` style nags PC's review
    > ruleset doesn't enforce. So: use **Plugin Check** to answer *"will this pass review?"*, and
    > **PHPCS/WPCS** for general code-quality and the fast, file-level security sniffs. WPCS is also
    > **bundled inside Plugin Check** (`plugin-check/vendor/…`) — that bundled copy is what emits PC's
    > `WordPress.Security.*` codes. To run phpcs against just the security sniffs quickly:
    > `phpcs --standard=WordPress --sniffs=WordPress.Security.EscapeOutput,WordPress.Security.NonceVerification,WordPress.Security.ValidatedSanitizedInput path/`.
    
    ### PHPStan — type & logic bugs PHPCS can't see
    
    ```bash
    composer require --dev phpstan/phpstan szepeviktor/phpstan-wordpress
    ```
    ```neon
    # phpstan.neon
    includes:
        - vendor/szepeviktor/phpstan-wordpress/extension.neon
    parameters:
        level: 5                      # 5 is a sane WordPress baseline; raise gradually
        paths:
            - includes
        # For Elementor/WooCommerce types, add their stubs so PHPStan stops reporting
        # "unknown class \Elementor\Widget_Base":
        # scanFiles:
        #     - vendor/php-stubs/woocommerce-stubs/woocommerce-stubs.php
    ```
    ```bash
    ./vendor/bin/phpstan analyse
    # Lock in unavoidable false positives (e.g. dynamic Elementor internals) as a baseline:
    ./vendor/bin/phpstan analyse --generate-baseline
    ```
    PHPStan catches things lint and PHPCS miss — e.g. an **untyped override of a typed Elementor
    method** (the white-screen fatal), a dead `is_string()` guard after `sanitize_text_field()`
    (`function.alreadyNarrowedType`), or a wrong return type. **Pair it with a typed
    `\Elementor\Widget_Base` stub** that instantiates the widget so signature mismatches fatal at
    class-declaration time during testing, not on the live site (see `field-notes.md` §1, §11).
    
    ### Plugin Check 2.1.0 — the reviewer's tool
    
    Full categories and usage are in **`wp-org-guidelines.md`**. For debugging:
    ```bash
    wp plugin check <your-plugin-slug> --format=json --exclude-directories=includes/lib
    ```
    Static checks run by default; **runtime** checks need `--require .../cli.php`. Run it until your
    own code is **0 findings** before every submission *and* every update (PC auto-scans updates since
    Oct 2025). JSON output is grouped under `FILE:` headers — parse JSON, not CSV (commas in messages
    break naive splitting).
    
    ### `$wpdb` sniffs & suppression mechanics — from real review rounds
    
    Every item below cost a review round on a live wp.org submission:
    
    - **Single-line `// phpcs:ignore` is unreliable on DB code — use the block form.** For any
      `$wpdb->…` call, wrap with `// phpcs:disable RuleA, RuleB` … `// phpcs:enable RuleA, RuleB`.
      Single-line ignores repeatedly failed to suppress `DirectDatabaseQuery.*` on
      `get_col()`/`DESCRIBE` lines; the block form always works.
    - **`$wpdb->prepare( $built_sql, $bind_array )` still trips
      `WordPress.DB.PreparedSQL.NotPrepared`** when the first argument is a *built variable* rather
      than a string literal — even though the call is correctly prepared. No dynamic query can satisfy
      the sniff: include `PreparedSQL.NotPrepared` in the disable block with a **true** justification
      ("identifiers are schema-whitelisted, values are bound").
    - **A valid `prepare()` needs at least one placeholder.** `prepare( $sql )` with no `%s`/`%d` is
      the "missing argument 2 for wpdb::prepare" anti-pattern — guarantee one (e.g. an always-present
      `LIMIT %d`).
    - **List EVERY sniff that fires, not just the obvious one.** A single direct `$wpdb` call can trip
      `DirectDatabaseQuery.DirectQuery`, `.NoCaching`, `.SchemaChange` (any DDL),
      `PreparedSQL.InterpolatedNotPrepared` **and**
      `PluginCheck.Security.DirectDB.UnescapedDBParameter` — each must be named in the disable block
      or it still reports.
    - **Exception messages are "output".** `throw new RuntimeException( "… $var" )` flags
      `EscapeOutput.ExceptionNotEscaped` — wrap the message (or its interpolated parts) in
      `esc_html()`.
    - **Schema and index questions are a different file.** These sniffs are about *suppression*; for
      the `CREATE TABLE` / `dbDelta()` rules, the `varchar(191)` index limit, `EXPLAIN` output and
      MariaDB↔MySQL portability, see **`mariadb.md`**.
    - **Core-private functions are forbidden.** E.g. `wp_get_sidebars_widgets()` is `@access private`
      — use `apply_filters( 'sidebars_widgets', get_option( 'sidebars_widgets', [] ) )` instead (and
      the re-applied CORE filter then needs a justified `NonPrefixedHooknameFound` ignore).
    - **`Requires at least` is a hard compatibility gate.** Plugin Check's
      `wp_function_not_compatible_with_requires_wp` is an ERROR keyed on the header —
      `function_exists()` guards do **NOT** satisfy it. If you call
      `str_starts_with()`/`str_contains()` (WP 5.9+ polyfills) or `wp_register_ability()` (WP 6.9+),
      set the header to the real floor.
    - **`Tested up to` going stale is an ERROR, and it costs you visibility.** Plugin Check raises
      `outdated_tested_upto_header` the moment a new WP major ships, and states the consequence
      plainly: the plugin *"will not show up in searches"* until the header matches. It is not a
      warning to defer — bump it as part of every release, and only after actually exercising the
      plugin on that version, which a throwaway local install of `latest.zip` makes cheap. Note that
      your dependencies lag independently: Elementor's own header sat at 7.0.4 days after WP 7.1
      shipped, which is normal and not a reason to hold your own back.
    
    ---
    
    ## 2. Runtime WordPress debugging
    
    Turn on debugging in **`wp-config.php`** (a dev/staging site — never `WP_DEBUG_DISPLAY` on prod):
    
    ```php
    define( 'WP_DEBUG',         true );   // enable error reporting
    define( 'WP_DEBUG_LOG',     true );   // write to wp-content/debug.log (or a path string)
    define( 'WP_DEBUG_DISPLAY', false );  // keep errors OUT of the page; read the log instead
    define( 'SCRIPT_DEBUG',     true );   // load unminified core/Elementor CSS & JS
    define( 'SAVEQUERIES',      true );   // record DB queries (Query Monitor / debugging only)
    @ini_set( 'display_errors', 0 );
    ```
    
    - **`wp-content/debug.log`** is the first place to look for a white screen or a 500 — the fatal's
      message, file, and line are logged there.
    - **Query Monitor** (free plugin) is the single most useful runtime tool: PHP errors, slow/duplicate
      queries, hooks fired, enqueued scripts/styles, REST calls, and which template/callback ran.
    - **Your own logging**, guarded so it never leaks on production:
      ```php
      if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
          error_log( 'myplugin: ' . wp_json_encode( $debug_data ) );
      }
      ```
      (Raw error text can contain API keys / user data — never log unguarded. See `php-standards.md`.)
    
    **White-screen-of-death (WSOD):** it's a PHP **fatal**. Enable `WP_DEBUG_LOG`, reload, read the
    last lines of `debug.log` — you get the exact file:line. The classic Elementor cause is an
    **untyped override** of `has_widget_inner_wrapper()` / `is_dynamic_content()` / `get_categories()`
    (runs on `wp_enqueue_scripts`, so it kills *every* page). `php -l` cannot catch it — PHPStan + a
    typed stub can.
    
    ---
    
    ## 3. Elementor-specific debugging
    
    - **Safe Mode** (Elementor → Tools → General → *Enable Safe Mode*) loads the editor with only
      Elementor active — isolates whether a bug is yours or a theme/third-party conflict.
    - **Regenerate Files & Data** (Elementor → Tools → General) — **the fix when a CSS change "didn't
      come through."** Elementor caches **per-page CSS**; new `--var` defaults or selector changes
      won't appear until you regenerate + hard-refresh. Tell the user this instead of assuming it's
      broken.
    - **System Info** (Elementor → System Info) — versions, server config, active experiments; paste it
      when diagnosing compatibility.
    - **Element cache freezes** = the "An error occurred" form / dead-redirect class of bug: a widget
      without `is_dynamic_content(): true` gets its HTML cached, freezing per-request nonces and
      `?redirect_to=`. Symptom looks like a logic bug; fix is the cache flag + clear Elementor cache.
    - **Editor preview blank / crashing** = a JS error in `content_template()` — a leaked
      `add_filter()` from `render()` (it re-runs on every control change), an "undefined index," or a
      triple-braced value. Open the **browser console while in the editor**; check that
      `content_template()` mirrors `render()`.
    - **`?elementor-preview` / Safe Mode** in the URL force-load the editor preview context — useful
      for reproducing editor-only behaviour from your URL-rewrite/context-detection code.
    
    ---
    
    ## 4. Frontend / JS / CSS debugging
    
    - **Browser console** — JS errors, and your own `console.error()` from AJAX handlers. An Elementor
      JS handler that "never runs" on an AJAX-loaded widget (popup, loop item) is almost always
      bound with `window.addEventListener('elementor/frontend/init', …)` (silently missed on
      Elementor < 3.5) or shipped with `strategy:'defer'` — use `jQuery(window).on(...)` and no defer
      (see `js-css-standards.md`).
    - **Network tab** — failed REST/AJAX calls, 403 from a stale/expired nonce (refresh-and-retry
      pattern in `rest-api.md`), wrong response shape.
    - **Inspect *computed* styles, not the stylesheet** — this is the only way to catch the bugs that
      "look fine" in code: a `var(--x)` that resolved to **nothing** (custom properties cascade
      parent→child only — see `field-notes.md` §4), a control selector that **lost on specificity** to
      theme/Elementor CSS, or a flex child overflowing because of a `min-width` floor. Screenshots
      alone miss all of these.
    - **`SCRIPT_DEBUG`** serves Elementor's unminified frontend JS so stack traces are readable.
    
    ---
    
    ## 5. Symptom → likely cause → where to look
    
    | Symptom | Likely cause | First check |
    |---|---|---|
    | **White screen on every page** | PHP fatal — often an untyped override of a typed Elementor method, or a parse error in an included file | `wp-content/debug.log` (exact file:line); then PHPStan + typed stub |
    | **Form shows "An error occurred" / redirect is dead** | Elementor element-cache froze a per-request nonce / `redirect_to` | Set `is_dynamic_content(): true`; clear Elementor cache |
    | **CSS change "didn't come through"** | Per-page CSS cache, or a `var()` resolving to nothing | **Regenerate Files & Data** + hard refresh; inspect **computed** styles for the empty var |
    | **Editor preview blank / broken** | `content_template()` JS error (triple-brace, leaked filter, undefined index) | Browser console **in the editor**; diff `content_template()` vs `render()` |
    | **Widget renders as "widget not found" after an update** | `get_name()` was changed (stored in `_elementor_data`) | Restore the old name, or migrate the stored JSON tokens (`field-notes.md` §1) |
    | **JS handler never fires on a popup / loop item** | Bound init with `addEventListener` (not jQuery) or used `strategy:defer` | `jQuery(window).on('elementor/frontend/init', …)`, no defer |
    | **Giant logo / broken layout when embedding an app** | Theme/Elementor CSS out-specifies single-class selectors | Boost base specificity (doubled class); inspect computed styles (`field-notes.md` §5) |
    | **wp.org review / Plugin Check flags `EscapeOutput`** | `echo $var` / `echo $this->method()` not escaped at output | Escape at the point of output; make methods echo literals + `esc_*` (`field-notes.md` §3) |
    | **Slow admin/front page** | N+1 queries / unbounded `WP_Query` | Query Monitor → Queries by component; add `no_found_rows`, scope `post_type` (`performance.md`) |
    | **Translation "doesn't load" though the `.mo` is right there** | The JIT loader only scans `WP_LANG_DIR/plugins/`, never the plugin's own `/languages/` | Put the compiled file in `wp-content/languages/plugins/{slug}-{locale}.mo` (`wordpress-apis.md` §6) |
    | **One style control does nothing, on one widget only** | Your own CSS neutralises that property with `!important`, outranking Elementor's generated rule | List the rules matching the element in a live DOM; drop the `!important` and win by specificity instead (`field-notes.md` §4) |
    | **A specific style control is overridden by a more generic one** | Both selectors match the element at equal specificity; the group registered LATER wins on source order | Scope the specific control one level deeper (`{{WRAPPER}} .panel .item`) (`field-notes.md` §4) |
    | **A filter/override applies to some strings on a page but not others** | The stubborn ones were baked into a config array at registration (`init`), before anything could hook the filter | Defer the value to a closure resolved at render time (`field-notes.md` §6) |
    
    ---
    
    ## 5b. Testing internationalization without fooling yourself
    
    i18n gives false negatives more readily than almost anything else — three separate traps produced
    "it's broken" results in one session when the code was fine:
    
    - **Switching locale mid-request proves nothing.** Adding a `locale` filter (or `switch_to_locale()`)
      *after* WordPress has bootstrapped leaves everything already registered in the old locale.
      Field labels, post-type labels and control labels are translated **once, on `init`** — flip the
      locale afterwards and those keep their English text while later `__()` calls come out
      translated. You then "discover" a bug that does not exist on a real request.
      **Do it properly:** make the site itself the target locale before bootstrapping —
      `wp language core install ka_GE` then set the `WPLANG` option — and run with no locale filters
      at all. Restore it afterwards.
    - **`switch_to_locale()` silently refuses** if that locale's core translation set isn't installed;
      `get_locale()` still returns `en_US` and every assertion "fails". Print `determine_locale()`
      before trusting any i18n test.
    - **WP-CLI caches the text domain during bootstrap.** By the time your `eval` runs, `$l10n[$domain]`
      is already populated (often as `NOOP_Translations`) and the just-in-time loader will not re-run.
      Call `unload_textdomain( $domain, true )` first, or the test reports English no matter what.
    
    Verify a catalogue mechanically before shipping it: assert that the printf placeholders
    (`%s`, `%d`, `%1$s`) and HTML tags in each `msgstr` match its `msgid`. A dropped or renamed
    placeholder is a runtime break that every PHP lint and unit test will pass.
    
    ---
    
    ## 6. Verification ≠ debugging
    
    A green lint/test run is not proof of correctness — see `field-notes.md` §11. Two reminders that
    prevent wasted hours:
    - **You usually can't see visual bugs from static checks.** Verify on a **live render** (the WP
      MCP bridge / a browser-driven preview), inspecting computed styles — not screenshots alone.
    - **When a check fails, suspect the check before the artifact.** Mis-scoped regex or byte-slicing
      multibyte UTF-8 (`head -c`/`tail -c` cutting a character mid-byte) produces confident false
      alarms. Verify the harness (paths, encodings, regex), then the file.
    
    ### Not every security-scanner finding is a bug (OWASP ZAP triage)
    
    Triage scanner output into four buckets before "fixing" anything — all four from one real ZAP run:
    
    | Bucket | Real example | Action |
    |---|---|---|
    | **Real** | `X-Content-Type-Options: nosniff` missing | Fix — add to ALL plugin responses |
    | **False positive** | "Debug Error Message" matched the literal words "PHP error log" in a tool *description* string | Document it — scanners substring-match benign doc text |
    | **Intentional** | `Access-Control-Allow-Origin: *` on public OAuth discovery docs only (no secrets; the sensitive endpoint keeps scoped CORS) | Keep the split; explain why |
    | **Environmental** | `X-Powered-By: PHP/8.x` (server `expose_php`), spec-required timestamps | Server config / protocol-required — not the plugin's bug |
    
  • elementor-extending.md 18.9 KB
    # Elementor — Extension Points Beyond Widgets
    
    > **When to read this file:** Extending Elementor *past* the basics in `elementor-patterns.md`
    > (widgets, dynamic tags, loop queries, form actions, theme conditions). This covers the rest of
    > the documented **V3 extension surface**: custom **form fields**, **theme locations**, injecting
    > controls into **native** widgets, **Finder** and **context-menu** items, plus a consolidated
    > **hooks** reference and the **deprecations** to avoid.
    >
    > Canonical source — verify signatures here, the API evolves: developers.elementor.com/docs/
    >
    > ℹ️ **V4 / Atomic note (verified mid-2026):** everything below is the stable **V3** extension API
    > (the skill's target). There is **still no published third-party PHP API for building custom
    > Atomic Elements** — no `Atomic_Widget_Base`; the official docs still point to `Widget_Base` for
    > custom widgets. Atomic Elements are documented only as a *data structure*
    > (developers.elementor.com/docs/data-structure/atomic-elements/), not a creation API. **Keep
    > building with V3 `Widget_Base`** — see SKILL.md §1. (Newer 4.x **control types** like
    > `VISUAL_CHOICE` are usable from V3 widgets today — see §8 below.)
    
    ---
    
    ## 1. Custom Form Field (Elementor Pro)
    
    The sibling of a Form **Action** (`elementor-patterns.md`). A custom **field** adds a new input
    type to the Form widget. Extends `\ElementorPro\Modules\Forms\Fields\Field_Base`.
    
    ```php
    // Requires: Elementor Pro
    class MyPlugin_Range_Field extends \ElementorPro\Modules\Forms\Fields\Field_Base {
    
        public function get_type(): string  { return 'myplugin_range'; }
        public function get_name(): string  { return esc_html__( 'Range Slider', 'myplugin' ); }
    
        // ✅ Pro 3.28+: declare field assets via METHODS (the old $depended_scripts /
        // $depended_styles properties on Field_Base are deprecated).
        public function get_script_depends(): array { return [ 'myplugin-range-field' ]; }
    
        // $item = this field's settings; $form = the Form widget instance.
        public function render( $item, $item_index, $form ): void {
            $form->add_render_attribute( 'input' . $item_index, [
                'type'  => 'range',
                'min'   => $item['min'] ?? 0,
                'max'   => $item['max'] ?? 100,
                'class' => 'elementor-field-textual myplugin-range',
            ] );
            // get_render_attribute_string() returns pre-built, safe attribute markup.
            echo '<input ' . $form->get_render_attribute_string( 'input' . $item_index ) . '>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Elementor-built attribute string
        }
    
        // Optional: add per-field settings controls to the Form's field repeater.
        public function update_controls( $widget ): void {
            $elementor = \ElementorPro\Plugin::elementor();
            $control   = $elementor->controls_manager->get_control_from_stack(
                $widget->get_unique_name(), 'form_fields'
            );
            if ( is_wp_error( $control ) ) { return; }
            $field_controls = [ /* … add 'min'/'max' controls with 'condition' => ['field_type'=>$this->get_type()] … */ ];
            $control['fields'] = \ElementorPro\Core\Utils::array_inject(
                $control['fields'], 'placeholder', $field_controls
            );
            $widget->update_control( 'form_fields', $control );
        }
    }
    
    // ✅ Register on the Pro fields registrar (parallels elementor_pro/forms/actions/register).
    add_action( 'elementor_pro/forms/fields/register', function( $fields_registrar ) {
        require_once MYPLUGIN_PATH . 'includes/class-myplugin-range-field.php';
        $fields_registrar->register( new MyPlugin_Range_Field() );
    } );
    ```
    
    **Validation / sanitization** of submitted values happens on the form lifecycle hooks, not the
    field class:
    ```php
    add_action( 'elementor_pro/forms/validation', function( $record, $ajax_handler ) {
        foreach ( $record->get_field( [ 'type' => 'myplugin_range' ] ) as $id => $field ) {
            if ( ! is_numeric( $field['value'] ) ) {
                $ajax_handler->add_error( $id, esc_html__( 'Invalid value.', 'myplugin' ) );
            }
        }
    }, 10, 2 );
    ```
    Other useful form hooks: `elementor_pro/forms/process` (after validation), and
    `elementor_pro/forms/new_record` (act on a successful submission).
    
    ---
    
    ## 2. Theme Locations (Elementor Pro Theme Builder)
    
    The sibling of a Theme **Condition**. A **location** is a slot a Theme Builder template can fill
    (your theme/plugin defines `header`, `footer`, `single`, or a custom one).
    
    ```php
    // Requires: Elementor Pro. Register the location.
    add_action( 'elementor/theme/register_locations', function( $manager ) {
        $manager->register_location( 'myplugin_before_content', [
            'label'           => esc_html__( 'Before Content', 'myplugin' ),
            'multiple'        => false,   // true = allow stacking multiple templates
            'edit_in_content' => true,    // edit inline in the content area
        ] );
    } );
    ```
    ```php
    // In the theme/plugin template, output the location with a graceful fallback:
    if ( function_exists( 'elementor_theme_do_location' ) ) {
        if ( ! elementor_theme_do_location( 'myplugin_before_content' ) ) {
            // No template assigned → render default markup here.
        }
    }
    ```
    Themes that want Elementor to fully manage header/footer declare support:
    `add_theme_support( 'elementor' );` and register their locations the same way.
    
    ---
    
    ## 3. Add controls to — or filter the output of — a NATIVE widget
    
    You don't always build a new widget; often you just want to extend an existing one.
    
    **Inject a control into a native widget's existing section** (no subclassing):
    ```php
    // Hook shape: elementor/element/{element_name}/{section_id}/before_section_end (or after_section_start)
    add_action( 'elementor/element/heading/section_title/before_section_end', function( $element, $args ) {
        $element->add_control( 'myplugin_badge', [
            'label' => esc_html__( 'Badge Text', 'myplugin' ),
            'type'  => \Elementor\Controls_Manager::TEXT,
        ] );
    }, 10, 2 );
    ```
    Find the `{element_name}` and `{section_id}` by inspecting the native widget's
    `register_controls()` (e.g. `heading` / `section_title`, `button` / `section_button`).
    
    **Filter a native widget's rendered HTML:**
    ```php
    add_filter( 'elementor/widget/render_content', function( string $content, $widget ): string {
        if ( 'heading' !== $widget->get_name() ) { return $content; }
        $badge = $widget->get_settings_for_display( 'myplugin_badge' );
        if ( $badge ) {
            $content .= '<span class="myplugin-badge">' . esc_html( $badge ) . '</span>';
        }
        return $content;
    }, 10, 2 );
    ```
    
    ---
    
    ## 4. Finder & Context Menu (editor UX — niche)
    
    **Finder** (Ctrl/Cmd+E quick search) — add your plugin's admin destinations:
    ```php
    add_action( 'elementor/finder/register', function( $categories_manager ) {
        $categories_manager->register( new MyPlugin_Finder_Category() );
    } );
    
    class MyPlugin_Finder_Category extends \Elementor\Core\Common\Modules\Finder\Base_Category {
        public function get_title(): string { return esc_html__( 'My Plugin', 'myplugin' ); }
        public function get_category_items( array $options = [] ): array {
            return [
                [
                    'title'    => esc_html__( 'Settings', 'myplugin' ),
                    'icon'     => 'settings',
                    'url'      => admin_url( 'admin.php?page=myplugin' ),
                    'keywords' => [ 'myplugin', 'settings', 'options' ],
                ],
            ];
        }
    }
    ```
    > ⚠️ The Finder registrar hook/signature has shifted across versions (older builds used the
    > `elementor/finder/categories` filter). Verify against current docs before shipping.
    
    **Context Menu** (editor right-click) is **JS-side**, via the editor hooks:
    ```js
    elementor.hooks.addFilter( 'elements/widget/contextMenuGroups', ( groups, view ) => {
        groups.push( {
            name:    'myplugin',
            actions: [ { name: 'my_action', title: 'My Action', callback: () => { /* … */ } } ],
        } );
        return groups;
    } );
    ```
    
    ---
    
    ## 5. Add a TAB to the editor's Elements panel (Widgets / Components / Globals / **yours**)
    
    The panel tab an SEO or content plugin wants: a first-class entry beside **Widgets**,
    **Components** and **Globals**, rendering your own UI inside the editor. Elementor **4.x ships an
    official API for this** — use it; do not scrape the panel markup (see the warning below).
    
    ```js
    // Elementor 4.x — the supported way. Registers the tab, its panel route AND the nav button.
    window.elementorV2.editorElementsPanel.injectTab( {
        id:        'myplugin',          // becomes the route  panel/elements/myplugin
        label:     'My Plugin',         // nav button text
        component: MyPanelComponent,    // a REACT component (window.React is global in the editor)
        position:  3,                   // optional index in the nav; omit to append
    } );
    ```
    
    `injectTab()` internally does all three things the old manual recipe needed — the
    `panel/elements/regionViews` filter (with an empty legacy Marionette view as a placeholder),
    `$e.components.get( 'panel/elements' ).addTab()`, and building the nav `<button>` — then renders
    your component through a React **Portal** into `#elementor-panel-elements-wrapper`.
    
    > **Verify against the shipped source, not memory.** This API is exported from Elementor's own
    > package — read
    > `elementor/assets/js/packages/editor-elements-panel/editor-elements-panel.js` in the installed
    > plugin (it is unminified) if behaviour ever changes. Confirmed present in **4.2.0**; the script
    > handle is **`elementor-v2-editor-elements-panel`**.
    
    **PHP side — enqueue into the editor only:**
    ```php
    add_action( 'elementor/editor/before_enqueue_scripts', 'myplugin_enqueue_panel' );
    
    function myplugin_enqueue_panel(): void {
        // The editor URL carries the post id; gate everything on it.
        $post_id = (int) get_the_ID();
        if ( $post_id <= 0 && isset( $_GET['post'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- identifying the open post, not processing a submission.
            $post_id = (int) $_GET['post'];
        }
        if ( $post_id <= 0 || ! current_user_can( 'edit_post', $post_id ) ) {
            return;
        }
    
        $deps = [ 'react', 'react-dom' ];
        // Only declare Elementor's handle when it exists, so an older build can't break the enqueue.
        if ( wp_script_is( 'elementor-v2-editor-elements-panel', 'registered' ) ) {
            $deps[] = 'elementor-v2-editor-elements-panel';
        }
    
        wp_enqueue_script( 'myplugin-panel', MYPLUGIN_URL . 'assets/panel.js', $deps, MYPLUGIN_VERSION, true );
        wp_enqueue_style(  'myplugin-panel', MYPLUGIN_URL . 'assets/panel.css', [], MYPLUGIN_VERSION );
    
        wp_add_inline_script(
            'myplugin-panel',
            'window.myPluginPanel = ' . wp_json_encode( [
                'postId'  => $post_id,
                'restUrl' => esc_url_raw( rest_url( 'myplugin/v1/' ) ),
                'nonce'   => wp_create_nonce( 'wp_rest' ),   // send as X-WP-Nonce
            ] ) . ';',
            'before'
        );
    }
    ```
    
    **Register defensively — a thrown error kills the whole panel:**
    ```js
    function injectTab() {
        var api = window.elementorV2 && window.elementorV2.editorElementsPanel;
        if ( ! api || 'function' !== typeof api.injectTab ) { return false; }
        api.injectTab( { id: 'myplugin', label: 'My Plugin', component: MyPanelComponent } );
        return true;
    }
    
    if ( ! injectTab() ) {
        // The elementorV2 packages may not have executed yet. Poll briefly, then give up QUIETLY:
        // on a build without the API, no tab is far better than an exception that breaks the panel.
        var tries = 0;
        var t = setInterval( function () { if ( injectTab() || ++tries > 60 ) { clearInterval( t ); } }, 100 );
    }
    ```
    
    > ⚠️ **Do NOT inject the tab by rewriting Elementor's rendered HTML.** A widely-shipped plugin
    > buffers `elementor/editor/footer` and `preg_replace()`s a `<button class="elementor-component-tab
    > elementor-panel-navigation-tab" data-tab="…">` in after the `data-tab="global"` one. It works
    > today and breaks silently the day Elementor renames a class — that plugin ships a fallback for
    > exactly that case. With `injectTab()` available there is no reason to take the risk.
    
    **No build step needed.** `window.React` is global in the editor, so `React.createElement` (with a
    local `var h = React.createElement`) gives you the whole component model with nothing to compile —
    which also keeps the shipped file readable for a wp.org reviewer.
    
    ### The two traps that lint clean and only show in a real editor
    
    **1. Anything the REST route calls must live OUTSIDE the `is_admin()` include block.** The panel
    reads and writes over REST, and **a REST request is not an admin request**. A rendering helper
    parked in an admin-only file (`meta-box.php` and friends) produces a fatal
    `Call to undefined function` the first time the endpoint runs — while the meta box using the same
    helper works perfectly. Keep shared presentation helpers in an always-loaded file.
    
    **2. The Elementor 4.x panel is LIGHT, not dark.** Style against Elementor's own custom properties
    and pick fallbacks that survive **both** themes:
    
    | Token | Value in 4.2 (light panel) |
    |---|---|
    | `--e-a-bg-default` | `#fff` |
    | `--e-a-color-txt` | `#515962` |
    | `--e-a-color-txt-muted` | `#818a96` |
    | `--e-a-border-color` | `#e6e8ea` |
    | `--e-a-bg-hover` / `--e-a-bg-active` | `#f1f2f3` / `#e6e8ea` |
    | `--e-a-color-info` | `#2563eb` |
    | `--e-a-btn-bg-primary` | **`#f3bafd`** — a pale pink that expects DARK text |
    
    - A `rgba(255,255,255,.12)` "subtle track/surface" fallback is **invisible** on the light panel.
      Use **neutral grey alpha** (`rgba(127,127,127,.2)`) so it reads on white *and* on dark.
    - White label text on `--e-a-btn-bg-primary` is unreadable — that token is pale pink. For a solid
      primary button use `var( --e-a-color-info, #2563eb )` with `#fff`.
    - Light-on-dark semantic colours (`#f0a3a3`, `#7fd0a8`…) vanish on white. Use mid-tones
      (`#c53030`, `#a97a12`, `#1a7f52`) that hold up in both.
    - **Verify by reading computed styles in a live editor**, not by eye — both of these pass every
      linter and look fine in the source.
    
    ---
    
    ## 6. Hooks quick-reference (the extension surface)
    
    **PHP — registration & rendering:**
    
    | Hook | Use |
    |---|---|
    | `elementor/widgets/register` | Register custom widgets (since 3.5; old: `widgets_registered`) |
    | `elementor/elements/categories_registered` | Register a widget panel category |
    | `elementor/dynamic_tags/register` | Register dynamic tags + tag groups |
    | `elementor/query/{query_id}` | Filter a Loop Grid / Posts query by Query ID |
    | `elementor/element/{el}/{section}/before_section_end` | Inject a control into a native widget |
    | `elementor/widget/render_content` (filter) | Modify a widget's rendered HTML |
    | `elementor/frontend/the_content` (filter) | Modify rendered Elementor page content |
    | `elementor/theme/register_conditions` | Custom Theme Builder display conditions (Pro) |
    | `elementor/theme/register_locations` | Custom Theme Builder locations (Pro) |
    | `elementor_pro/forms/actions/register` | Custom form submit action (Pro) |
    | `elementor_pro/forms/fields/register` | Custom form field type (Pro) |
    | `elementor_pro/forms/validation` / `process` / `new_record` | Form submission lifecycle (Pro) |
    | `elementor/finder/register` | Add Finder items |
    | `elementor/frontend/after_register_scripts` / `after_enqueue_styles` | Frontend asset timing |
    | `elementor/editor/after_enqueue_scripts` / `after_enqueue_styles` | Editor-only assets |
    | `elementor/editor/before_enqueue_scripts` | Editor assets, early — use for a panel-tab bundle (§5) |
    | `elementor/preview/enqueue_styles` | Preview-iframe-only assets |
    
    **JS — editor & frontend:**
    
    | Hook | Use |
    |---|---|
    | `elementor/frontend/init` (bind with **jQuery** — see `js-css-standards.md`) | Frontend boot |
    | `frontend/element_ready/{widget}.default` | Per-widget frontend handler (incl. AJAX-loaded) |
    | `panel/open_editor/widget/{widget}` | Editor panel opened for a widget |
    | `elements/widget/contextMenuGroups` (filter) | Editor context-menu items |
    | `elementorV2.editorElementsPanel.injectTab()` (4.x API, not a hook) | Add a tab to the Elements panel (§5) |
    | `panel/elements/regionViews` (filter) | Low-level panel regions — `injectTab()` wraps this; prefer it |
    
    ---
    
    ## 7. Deprecations to avoid
    
    These still "work" but are deprecated — using them invites breakage and Plugin Check / review
    flags. Use the right-hand column.
    
    | Deprecated | Use instead | Since |
    |---|---|---|
    | `get_id_int()` | `get_id()` | 3.1 |
    | `_register_controls()` | `register_controls()` | 3.1 |
    | `_content_template()` | `content_template()` | 3.1 |
    | `$depended_scripts` / `$depended_styles` properties | `get_script_depends()` / `get_style_depends()` methods | 3.24 / Pro 3.28 |
    | `Scheme_Color` / `Scheme_Typography`, `Group_Control_Scheme_*` | `Global_Colors` / `Global_Typography` globals | 3.0 |
    | `elementor/widgets/widgets_registered` | `elementor/widgets/register` | 3.5 |
    | Targeting `.elementor-widget-container` in CSS/JS | the widget root / your own BEM class (+ `has_widget_inner_wrapper(): false`) | 3.25+ / V4 |
    | `window.addEventListener('elementor/frontend/init', …)` as the *only* binding | `jQuery(window).on('elementor/frontend/init', …)` | — |
    
    > Elementor's own deprecation log: developers.elementor.com/docs/deprecations/ — check it each
    > major release; deprecated APIs get a `_deprecated_*` notice under `WP_DEBUG`.
    
    ---
    
    ## 8. Newer control types (4.x) — `VISUAL_CHOICE`
    
    A V3-compatible control added in the 4.x line: an **image-based** choice picker (each option is a
    visual/SVG, not just an icon-glyph like `CHOOSE`). Ideal for layout / skin / structure pickers.
    
    ```php
    $this->add_control( 'structure', [
        'label'       => esc_html__( 'Layout', 'myplugin' ),
        'type'        => \Elementor\Controls_Manager::VISUAL_CHOICE,
        'default'     => 'grid',
        'label_block' => true,
        'columns'     => 2,                       // grid columns in the panel
        'options'     => [
            'grid'    => [
                'title' => esc_attr__( 'Grid', 'myplugin' ),
                'image' => plugins_url( 'assets/img/layout-grid.svg', MYPLUGIN_FILE ),
            ],
            'masonry' => [
                'title' => esc_attr__( 'Masonry', 'myplugin' ),
                'image' => plugins_url( 'assets/img/layout-masonry.svg', MYPLUGIN_FILE ),
            ],
        ],
        // Like CHOOSE, you can drive CSS directly when the value maps to a class/value:
        'prefix_class' => 'myplugin-layout-',     // → 'myplugin-layout-grid' on the wrapper
    ] );
    
    // render(): the value is the selected option KEY (a string).
    $layout = $this->get_settings_for_display( 'structure' );  // 'grid' | 'masonry'
    ```
    
    - Value is the **option key string** (read with `get_settings_for_display('structure')`).
    - `image` is a URL — ship your own SVGs; don't `wp_kses()` them (see `field-notes.md` §6 / §2 on
      the `viewBox` lowercasing trap if you ever inline them).
    - Compare with **`CHOOSE`** (icon-glyph toggle, e.g. alignment) and **`SELECT`/`SELECT2`** (plain
      dropdown) — pick `VISUAL_CHOICE` only when an image genuinely communicates the option better.
    > Source: developers.elementor.com/docs/editor-controls/control-visual-choice/
    
  • elementor-patterns.md 28.5 KB
    # Elementor-Specific Patterns
    
    ## V4 Compatibility Rules (Apply Now)
    
    > ✅ **V4 / Elementor 4.x is STABLE (current: 4.2) — apply these rules to all new code:**
    > 1. **NEVER target `.elementor-widget-container` in CSS or JS** — does NOT exist in V4
    >    Atomic Elements; removed from V3 widgets when Optimized Markup is active (opt-in in
    >    3.35.x, **on by default for all new sites since Elementor 4.0**). Write all new code
    >    as if this wrapper is absent.
    >    Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    > 2. **ALWAYS include `has_widget_inner_wrapper(): false`** on every new widget.
    > 3. **Do NOT use `strategy: 'defer'`** on scripts that listen for `elementor/frontend/init`.
    > 4. **V3 `Widget_Base` is fully supported in Elementor 4.0.** Use V3 for all third-party
    >    widgets — it is the correct, production-safe API. The V4 Atomic Element PHP extension
    >    API is Stable in 4.0 but third-party PHP extension documentation is still being finalized.
    >    Continue using V3 `Widget_Base` until official V4 extension docs are published.
    >    Source: elementor.com/products/website-builder/v4-faq/ · developers.elementor.com/elementor-editor-4-0-developers-update/
    > 5. **Elementor 4.0+ Admin Menu:** Register plugin menus as standard WordPress admin pages
    >    outside the Elementor parent slug. Non-standard injection into the Elementor admin area
    >    is silently dropped (unchanged from 3.34.2 behaviour).
    ---
    
    ## Mandatory: No Hardcoded Visuals — Every Widget Must Use Controls
    
    > ⚠️ **This is a MANDATORY rule — see SKILL.md §0 Golden Rule #6 and §5 for the full checklist.**
    >
    > **NEVER hardcode** colors, fonts, sizes, spacing, backgrounds, borders, shadows, or any
    > visual property in `render()`, in static CSS files, or in `content_template()`. ALL visual
    > properties must be Elementor controls using `selectors` to inject CSS.
    >
    > **For every text element:** `Group_Control_Typography` + `COLOR` control.
    > **For every box/container:** `Group_Control_Background` + `Group_Control_Border` +
    > `Group_Control_Box_Shadow` + `DIMENSIONS` (padding/margin/border-radius).
    > **For every spacing value:** `add_responsive_control()` with `SLIDER` or `DIMENSIONS`.
    > **For every interactive state:** Duplicate controls for `:hover` in a separate section.
    >
    > Source: developers.elementor.com/docs/widgets/widget-controls/
    > Source: developers.elementor.com/docs/editor-controls/group-control/
    > Source: developers.elementor.com/docs/editor-controls/responsive-control/
    
    ---
    
    ## Custom Widget — Complete Pattern
    
    **Step 1 — Plugin header must declare Elementor dependency (WP 6.5+):**
    ```php
    /**
     * Plugin Name:  My Plugin
     * Requires Plugins: elementor
     * Requires PHP: 8.3
     * Requires at least: 6.9
     */
    ```
    > ⚠️ Do NOT add inline comments on the `Requires Plugins:` line — `get_file_data()` reads
    > everything after the colon as the value. Keep the `did_action('elementor/loaded')` runtime
    > check (see scaffolding.md) as a fallback for pre-6.5 sites.
    
    **Step 2 — Register widget assets on `wp_enqueue_scripts` (register only — do NOT enqueue):**
    ```php
    // ⚠️ DO NOT add strategy:'defer' to widget scripts that use elementor/frontend/init.
    // Use ['in_footer' => true] WITHOUT strategy:defer. The 'elementor-frontend' dependency
    // guarantees document-order execution AFTER Elementor's script.
    add_action( 'wp_enqueue_scripts', function() {
        wp_register_script(
            'myplugin-widget-js',
            MYPLUGIN_URL . 'assets/js/myplugin-widget.js',
            [ 'elementor-frontend' ],  // ✅ dependency guarantees load order
            MYPLUGIN_VERSION,
            [ 'in_footer' => true ]    // ✅ no defer — see note above
        );
        wp_register_style(
            'myplugin-widget-css',
            MYPLUGIN_URL . 'assets/css/myplugin-widget.css',
            [],
            MYPLUGIN_VERSION
        );
    } );
    ```
    
    **Step 3 — Widget class with all required methods:**
    ```php
    // includes/class-myplugin-widget.php
    defined( 'ABSPATH' ) || exit;
    
    class MyPlugin_Widget extends \Elementor\Widget_Base {
    
        public function get_name(): string        { return 'myplugin-widget'; }
        public function get_title(): string       { return esc_html__( 'My Widget', 'myplugin' ); }
        public function get_icon(): string        { return 'eicon-code'; }
        public function get_categories(): array   { return [ 'general' ]; }
        public function get_keywords(): array     { return [ 'custom', 'myplugin' ]; }
    
        // ✅ REQUIRED: Declare JS dependencies — Elementor loads these only on pages using this widget
        public function get_script_depends(): array {
            return [ 'myplugin-widget-js' ];
        }
    
        // ✅ REQUIRED: Declare CSS dependencies
        public function get_style_depends(): array {
            return [ 'myplugin-widget-css' ];
        }
    
        // ✅ REQUIRED on ALL new widgets — removes the redundant inner wrapper div
        // (.elementor-widget-container). Return true ONLY if your render() output
        // physically requires that inner wrapper.
        public function has_widget_inner_wrapper(): bool {
            return false;
        }
    
        // ✅ Return false if widget renders static content — enables Elementor output caching.
        // Return true if output depends on current user, session, time, or other dynamic data.
        protected function is_dynamic_content(): bool {
            return false;
        }
    
        protected function register_controls(): void {
    
            $this->start_controls_section( 'section_content', [
                'label' => esc_html__( 'Content', 'myplugin' ),
                'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
            ] );
    
            $this->add_control( 'title', [
                'label'              => esc_html__( 'Title', 'myplugin' ),
                'type'               => \Elementor\Controls_Manager::TEXT,
                'default'            => esc_html__( 'Default Title', 'myplugin' ),
                'label_block'        => true,
                'frontend_available' => true,
            ] );
    
            $this->end_controls_section();
    
            $this->start_controls_section( 'section_style', [
                'label' => esc_html__( 'Title Style', 'myplugin' ),
                'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
            ] );
    
            // ✅ RESPONSIVE alignment — user sets per-breakpoint via device icons
            $this->add_responsive_control( 'title_align', [
                'label'   => esc_html__( 'Alignment', 'myplugin' ),
                'type'    => \Elementor\Controls_Manager::CHOOSE,
                'options' => [
                    'left'   => [ 'title' => esc_html__( 'Left',   'myplugin' ), 'icon' => 'eicon-text-align-left' ],
                    'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ],
                    'right'  => [ 'title' => esc_html__( 'Right',  'myplugin' ), 'icon' => 'eicon-text-align-right' ],
                ],
                'selectors' => [
                    '{{WRAPPER}} .myplugin-widget__title' => 'text-align: {{VALUE}};',
                ],
            ] );
    
            $this->add_control( 'title_color', [
                'label'     => esc_html__( 'Color', 'myplugin' ),
                'type'      => \Elementor\Controls_Manager::COLOR,
                'selectors' => [
                    // ✅ {{WRAPPER}} resolves to .elementor-widget-myplugin-widget (widget root)
                    // NEVER use .elementor-widget-container here — see V4 rules above
                    '{{WRAPPER}} .myplugin-widget__title' => 'color: {{VALUE}};',
                ],
            ] );
    
            // ✅ Group_Control_Typography — MANDATORY for every text element.
            // Provides font-family, size, weight, transform, style, decoration, line-height,
            // letter-spacing, and word-spacing — all responsive. NEVER hardcode any of these.
            $this->add_group_control(
                \Elementor\Group_Control_Typography::get_type(),
                [
                    'name'     => 'title_typography',
                    'selector' => '{{WRAPPER}} .myplugin-widget__title',
                ]
            );
    
            // ✅ Group_Control_Text_Shadow — standard for heading/title elements
            $this->add_group_control(
                \Elementor\Group_Control_Text_Shadow::get_type(),
                [
                    'name'     => 'title_text_shadow',
                    'selector' => '{{WRAPPER}} .myplugin-widget__title',
                ]
            );
    
            // ✅ RESPONSIVE spacing — use SLIDER with size_units for any spacing value
            $this->add_responsive_control( 'title_spacing', [
                'label'      => esc_html__( 'Bottom Spacing', 'myplugin' ),
                'type'       => \Elementor\Controls_Manager::SLIDER,
                'size_units' => [ 'px', 'em', 'rem' ],
                'range'      => [
                    'px'  => [ 'min' => 0, 'max' => 100 ],
                    'em'  => [ 'min' => 0, 'max' => 10 ],
                ],
                'selectors'  => [
                    '{{WRAPPER}} .myplugin-widget__title' => 'margin-bottom: {{SIZE}}{{UNIT}};',
                ],
            ] );
    
            $this->end_controls_section();
        }
    
        protected function render(): void {
            $settings = $this->get_settings_for_display();
    
            // ✅ Null-safe check — Plugin::$instance->editor is null on the frontend
            $is_editor = \Elementor\Plugin::$instance->editor
                         && \Elementor\Plugin::$instance->editor->is_edit_mode();
    
            // ✅ ALWAYS use add_render_attribute() — the official Elementor API for building
            // HTML attributes. Never manually concatenate class strings in render().
            // Source: developers.elementor.com/docs/widgets/rendering-html-attribute/
            $this->add_render_attribute( 'title', 'class', 'myplugin-widget__title' );
            // ✅ add_inline_editing_attributes() enables live text editing in the panel.
            // Source: developers.elementor.com/docs/widgets/rendering-inline-editing/
            $this->add_inline_editing_attributes( 'title' );
            ?>
            <div class="myplugin-widget">
                <h2 <?php $this->print_render_attribute_string( 'title' ); ?>>
                    <?php echo esc_html( $settings['title'] ); ?>
                </h2>
                <?php if ( $is_editor ) : ?>
                    <span class="myplugin-widget__editor-hint" style="opacity:0.5;font-size:0.75rem;">
                        <?php esc_html_e( '[Editor preview]', 'myplugin' ); ?>
                    </span>
                <?php endif; ?>
            </div>
            <?php
        }
    
        // ✅ REQUIRED: JS/Backbone template for live editor preview.
        // ⚠️ ESCAPING: use {{ }} (escaped) for USER SETTINGS — title, text, labels, button text.
        //    {{{ }}} (raw) on a user-controlled value is an editor-context XSS and wp.org plugin
        //    review REJECTS it. {{ }} escapes via _.escape(); the browser decodes the entity on
        //    render (O'Reilly displays correctly — there is NO apostrophe corruption).
        //    Reserve {{{ }}} for Elementor-GENERATED HTML only (e.g. {{{ iconHTML.value }}} from
        //    elementor.helpers.renderIcon, processed media) — never raw settings, and never inside
        //    an HTML attribute (a triple-brace breaks the attribute). See field-notes.md §2.
        //    getRenderAttributeString() output IS safe Elementor-built markup → triple-brace is OK.
        protected function content_template(): void {
            ?>
            <div class="myplugin-widget">
                <# view.addRenderAttribute( 'title', 'class', 'myplugin-widget__title' );
                   view.addInlineEditingAttributes( 'title' ); #>
                <h2 {{{ view.getRenderAttributeString( 'title' ) }}}>{{ settings.title }}</h2>
            </div>
            <?php
        }
    }
    
    // ✅ REQUIRED registration hook
    add_action( 'elementor/widgets/register', function( \Elementor\Widgets_Manager $manager ) {
        require_once MYPLUGIN_PATH . 'includes/class-myplugin-widget.php';
        $manager->register( new MyPlugin_Widget() );
    } );
    ```
    
    ---
    
    ## Dynamic Tag Registration
    
    > **Dynamic Tag Category Reference (current as of Elementor 4.2; unchanged since 3.x):**
    >
    > | Constant | Value | Available in | Use for |
    > |---|---|---|---|
    > | `Module::TEXT_CATEGORY` | `'text'` | **Free** | Single-line text fields |
    > | `Module::URL_CATEGORY` | `'url'` | **Free** | URL/link controls |
    > | `Module::IMAGE_CATEGORY` | `'image'` | **Free** | Media/image controls |
    > | `Module::MEDIA_CATEGORY` | `'media'` | **Free** | Alias for IMAGE_CATEGORY |
    > | `Module::NUMBER_CATEGORY` | `'number'` | **Free** | Slider/number controls |
    > | `Module::COLOR_CATEGORY` | `'color'` | **Free** | Color controls |
    > | `Module::POST_GROUP` | `'post'` | **Pro only** | ⚠️ Fatal error without Pro |
    > | `Module::SITE_GROUP` | `'site'` | **Pro only** | ⚠️ Fatal error without Pro |
    >
    > Always use the class constant (`Module::TEXT_CATEGORY`) not the raw string — the string
    > values are internal and could change. Source: `elementor/modules/dynamic-tags/module.php`
    
    > ⚠️ **Pick the right PARENT or the tag silently returns nothing.** There are two, and the
    > choice is dictated by the category:
    >
    > | Parent | `get_content_type()` | How Elementor resolves it | Use for |
    > |---|---|---|---|
    > | `\Elementor\Core\DynamicTags\Tag` | `'ui'` (**`final`**) | `ob_start(); $this->render();` | TEXT, URL, COLOR, NUMBER |
    > | `\Elementor\Core\DynamicTags\Data_Tag` | `'plain'` (**`final`**) | `return $this->get_value( $options );` | **IMAGE / MEDIA / GALLERY** |
    >
    > An image tag written as `extends Tag` with a `get_value()` returning `['id','url']` looks
    > completely correct and **never runs**: `get_content_type()` is `final`, so the tag stays
    > 'ui', Elementor output-buffers `render()`, and `get_value()` is dead code. The image control
    > receives an empty string and the field appears to have no value — with no error anywhere.
    > Symptom to recognise: *"dynamic fields can't pull the images"* while text tags work fine.
    >
    > `Data_Tag::get_value()` is declared `abstract protected`, so match that visibility. Return
    > `[ 'id' => (int) $attachment_id, 'url' => (string) $url ]`, and fall back to
    > `\Elementor\Utils::get_placeholder_image_src()` when the attachment is gone — otherwise a
    > deleted image emits a broken `src`.
    >
    > Because the two parents are incompatible, put shared field-lookup logic in a **trait**, not a
    > common base class:
    > ```php
    > trait My_Tag_Fields { /* register_controls(), field_value(), get_group() … */ }
    > abstract class My_Tag_Base extends \Elementor\Core\DynamicTags\Tag { use My_Tag_Fields; }
    > class My_Image_Tag extends \Elementor\Core\DynamicTags\Data_Tag { use My_Tag_Fields; }
    > ```
    > Sanity check for `Tag`-based tags: `Tag::WRAPPED_TAG` defaults to **`false`**, so URL and
    > COLOR output is not span-wrapped. If a subclass sets it `true`, a colour or URL tag returns
    > HTML and the CSS value breaks.
    
    > ⚠️ **An empty image field must return an EMPTY value — never a placeholder.**
    > `Utils::get_placeholder_image_src()` looks like a helpful fallback and is the wrong call:
    > Elementor's image widget opens with
    > `if ( empty( $settings['image']['url'] ) ) { return; }` (`includes/widgets/image.php`), so an
    > empty `url` is precisely what makes it render nothing. Return a placeholder instead and every
    > unset field paints a grey Elementor placeholder on the front end, which the site owner then
    > has to hide with custom CSS.
    >
    > This bites hardest on **repeated optional slots** — `cert_img_1..4`, gallery slots, secondary
    > logos. A field sanitised with `absint` stores **`0`** when nothing is chosen, and `0` is not an
    > image; an instructor with two certificates renders two real images and two placeholders.
    >
    > **Elementor Pro's own `ACF_Image` tag is the reference implementation**
    > (`elementor-pro/modules/dynamic-tags/acf/tags/acf-image.php`):
    > ```php
    > $image_data = [ 'id' => null, 'url' => '' ];        // starts EMPTY
    > // …resolve the field…
    > if ( empty( $value ) && $this->get_settings( 'fallback' ) ) {
    >     $value = $this->get_settings( 'fallback' );      // only a USER-CHOSEN fallback
    > }
    > if ( ! empty( $value ) && is_array( $value ) ) { /* fill id + url */ }
    > return $image_data;                                  // empty when nothing is set
    > ```
    > It also registers a `Controls_Manager::MEDIA` control named `fallback`, so the *user* decides
    > whether an empty field shows something. Copy that shape: empty by default, opt-in fallback.
    > Treat a set id whose attachment was deleted the same way — asserting an image exists when it
    > does not is the bug, not the cure.
    >
    > Same principle as the colour-control rule in **SKILL.md §5**: do not invent a default the user
    > cannot switch off. If the platform ships a reference implementation for the thing you are
    > building, read it before inventing behaviour.
    
    > 🎠 **Carousels and sliders accept ONLY the GALLERY category — an IMAGE tag cannot fill them.**
    > So a set of separate single-image fields (`cert_img_1..4`, `logo_a`/`logo_b`, gallery slots on a
    > repeater) is unusable in a slider until you add a tag that synthesises them into one gallery.
    > `Module::GALLERY_CATEGORY` is defined in **free** Elementor, so this needs no Pro. Gallery tags
    > extend **`Data_Tag`**, same as image tags, for the same `get_content_type()` reason.
    >
    > **Return a flat list of `array( 'id' => int )`.** `id` is mandatory — verified against the
    > consumers, not assumed from one example:
    >
    > | Consumer | How it reads each item |
    > |---|---|
    > | Elementor **Pro** Gallery | `wp_get_attachment_image_src( $item['id'], … )` — **id only, no url fallback** |
    > | free Image Gallery | `wp_list_pluck( $items, 'id' )` |
    > | free Image Carousel | `Group_Control_Image_Size::get_attachment_image_src( $item['id'], … )`, reads `$item['url']` **only if that returns false** |
    > | Pro `ACF_Gallery` (reference) | returns `[ 'id' => $image['ID'] ]` |
    >
    > Captions, alt text and lightbox links all key off `id` too, so an id-less item degrades badly.
    > Adding `url` alongside `id` is harmless — no first-party widget prefers it over its own sized
    > version — and is worth doing defensively, because a third-party carousel that naively reads
    > `$item['url']` renders blank slides otherwise.
    >
    > Skip empty slots and ids whose attachment no longer resolves, and return an **empty array** when
    > there is nothing: a carousel then renders nothing instead of blank slides. Derive the slot list
    > from your field schema rather than hardcoding it, so adding a fifth slot needs no tag change.
    >
    > **Method worth repeating:** the reference implementation tells you what is *sufficient*; the
    > consumers tell you what is *required*. ACF's gallery tag returns id-only, which is sufficient —
    > but only reading Pro's Gallery widget proves `id` is mandatory with no fallback, and only
    > surveying third-party widgets justifies carrying `url` as well. Check both ends.
    >
    > Also worth checking before you build: whether the system you are replacing solved the problem at
    > all. `ACF_Gallery::get_supported_fields()` returns `['gallery']`, so on a site whose field group
    > has only `image` fields it offers an empty picker — the capability never existed, and you are
    > adding it rather than porting it.
    
    ```php
    // ✅ Type hint \Elementor\Core\DynamicTags\Manager (different from Widgets_Manager)
    add_action( 'elementor/dynamic_tags/register', function( \Elementor\Core\DynamicTags\Manager $manager ) {
        // ✅ Register the custom group BEFORE registering the tag that references it.
        // ⚠️ Do NOT use \Elementor\Modules\DynamicTags\Module::POST_GROUP here.
        // POST_GROUP is Elementor Pro-only — causes PHP fatal error if Pro is not active.
        $manager->register_group( 'myplugin', [
            'title' => esc_html__( 'My Plugin', 'myplugin' ),
        ] );
        require_once MYPLUGIN_PATH . 'includes/class-myplugin-dynamic-tag.php';
        $manager->register( new MyPlugin_Dynamic_Tag() );
    } );
    
    class MyPlugin_Dynamic_Tag extends \Elementor\Core\DynamicTags\Tag {
        public function get_name(): string     { return 'myplugin-tag'; }
        public function get_title(): string    { return esc_html__( 'My Custom Tag', 'myplugin' ); }
        // ✅ get_group() MUST return array — returning a string causes PHP TypeError
        public function get_group(): array     { return [ 'myplugin' ]; }
        public function get_categories(): array {
            // ✅ TEXT_CATEGORY IS defined in free Elementor — safe to use without Pro
            return [ \Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY ];
        }
    
        public function render(): void {
            $value = get_post_meta( get_the_ID(), '_my_custom_field', true );
            echo esc_html( (string) $value );
        }
    }
    ```
    
    ---
    
    ## Loop Grid — Custom Query Filter
    
    ```php
    // In Elementor editor → Query ID field: set to "myplugin_loop_query"
    add_action( 'elementor/query/myplugin_loop_query', function( \WP_Query $query ) {
        $query->set( 'post_type',      'product' );
        $query->set( 'posts_per_page', 6 );
        $query->set( 'no_found_rows',  true ); // skip COUNT(*) when no pagination needed
        $query->set( 'tax_query', [ [
            'taxonomy' => 'product_cat',
            'field'    => 'slug',
            'terms'    => [ 'featured' ],
        ] ] );
    } );
    ```
    
    ---
    
    ## ACF + Elementor Dynamic Tag (Full Pattern)
    
    ```php
    class MyPlugin_ACF_Tag extends \Elementor\Core\DynamicTags\Tag {
        public function get_name(): string     { return 'myplugin-acf-tag'; }
        public function get_title(): string    { return esc_html__( 'ACF Field', 'myplugin' ); }
        public function get_group(): array     { return [ 'myplugin' ]; }
        public function get_categories(): array {
            return [ \Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY ];
        }
    
        protected function register_controls(): void {
            $this->add_control( 'field_key', [
                'label' => esc_html__( 'ACF Field Key or Name', 'myplugin' ),
                'type'  => \Elementor\Controls_Manager::TEXT,
            ] );
        }
    
        public function render(): void {
            $field_key = $this->get_settings( 'field_key' );
            if ( empty( $field_key ) || ! function_exists( 'get_field' ) ) return;
            // ✅ sanitize_text_field() NOT sanitize_key() — sanitize_key() lowercases and
            // strips non-[a-z0-9_-] characters, silently corrupting mixed-case ACF field names.
            $value = get_field( sanitize_text_field( $field_key ) );
    
            // get_field() can return arrays (repeaters) — guard against it
            if ( is_array( $value ) || is_object( $value ) ) return;
    
            echo esc_html( (string) $value );
        }
    }
    
    add_action( 'elementor/dynamic_tags/register', function( \Elementor\Core\DynamicTags\Manager $manager ) {
        // register_group() is idempotent — safe to call in multiple callbacks
        $manager->register_group( 'myplugin', [
            'title' => esc_html__( 'My Plugin', 'myplugin' ),
        ] );
        require_once MYPLUGIN_PATH . 'includes/class-myplugin-acf-tag.php';
        $manager->register( new MyPlugin_ACF_Tag() );
    } );
    ```
    
    ---
    
    ## Elementor Pro Form — Custom Action (Elementor Pro 3.28+)
    
    Elementor Pro 3.28 (March 2025) updated the Form **Field** API (`Field_Base`) so fields
    can declare JS/CSS dependencies via methods (identical to widget `get_script_depends()`)
    instead of the old `$depended_scripts` / `$depended_styles` properties (now deprecated
    on `Field_Base`). Form Actions (`Action_Base`) are not part of this documented change.
    
    ```php
    // Requires: Elementor Pro
    class MyPlugin_Form_Action extends \ElementorPro\Modules\Forms\Classes\Action_Base {
    
        public function get_name(): string  { return 'myplugin-action'; }
        public function get_label(): string { return esc_html__( 'My Plugin Action', 'myplugin' ); }
    
        public function get_script_depends(): array {
            return [ 'myplugin-form-js' ];
        }
    
        public function get_style_depends(): array {
            return [ 'myplugin-form-css' ];
        }
    
        // ✅ Type hint omitted intentionally — Elementor does not expose a public interface/class
        // for the $widget parameter. Using \Elementor\Widget_Base is the closest match but
        // Elementor passes its internal form widget class. Omit type hint to avoid TypeError.
        public function register_settings_section( $widget ): void {
            $widget->start_controls_section( 'myplugin_action_section', [
                'label'     => esc_html__( 'My Plugin', 'myplugin' ),
                'condition' => [ 'submit_actions' => $this->get_name() ],
            ] );
    
            $widget->add_control( 'myplugin_endpoint', [
                'label' => esc_html__( 'Endpoint URL', 'myplugin' ),
                'type'  => \Elementor\Controls_Manager::TEXT,
            ] );
    
            $widget->end_controls_section();
        }
    
        public function on_export( array $element ): array {
            return $element;
        }
    
        // $record = \ElementorPro\Modules\Forms\Classes\Form_Record
        // $ajax_handler = \ElementorPro\Modules\Forms\Classes\Ajax_Handler
        // Both classes are Pro-only — do NOT type hint them directly here or PHP
        // will throw a fatal error on sites where Elementor Pro is inactive.
        public function run( $record, $ajax_handler ): void {
            $settings = $record->get( 'form_settings' );
            $endpoint = sanitize_url( $settings['myplugin_endpoint'] ?? '' );
    
            if ( empty( $endpoint ) ) return;
    
            // ✅ $record->get('fields') returns an associative array keyed by field ID.
            // Loop key $id IS the field ID — do NOT use $field['id'].
            $raw_fields = $record->get( 'fields' );
            $payload    = [];
            foreach ( $raw_fields as $id => $field ) {
                $payload[ sanitize_key( $id ) ] = sanitize_text_field( $field['value'] );
            }
    
            $response = wp_remote_post( $endpoint, [
                'body'    => wp_json_encode( $payload ),
                'headers' => [ 'Content-Type' => 'application/json' ],
                'timeout' => 15,
            ] );
    
            if ( is_wp_error( $response ) ) {
                $ajax_handler->add_error_message(
                    esc_html__( 'Submission failed. Please try again.', 'myplugin' )
                );
            }
        }
    }
    
    add_action( 'elementor_pro/forms/actions/register', function( $form_actions_registrar ) {
        require_once MYPLUGIN_PATH . 'includes/class-myplugin-form-action.php';
        $form_actions_registrar->register( new MyPlugin_Form_Action() );
    } );
    ```
    
    ---
    
    ## Elementor Theme Builder — Custom Display Conditions
    
    > ⚠️ **API scope:** Only registering **new condition types** via
    > `elementor/theme/register_conditions` is the public API. Programmatically
    > **assigning** conditions to an existing template is undocumented internal operation.
    
    ```php
    // Requires: Elementor Pro
    class MyPlugin_Logged_In_Condition extends \ElementorPro\Modules\ThemeBuilder\Conditions\Condition_Base {
    
        // 'general' = site-wide; 'singular' = singular posts/pages; 'archive' = archive pages
        public static function get_type(): string { return 'general'; }
        public function get_name(): string        { return 'myplugin_logged_in'; }
        public function get_label(): string       { return esc_html__( 'Logged-In User', 'myplugin' ); }
        public function get_all_label(): string   { return esc_html__( 'All Logged-In Users', 'myplugin' ); }
    
        public function check( $args ): bool {
            return is_user_logged_in();
        }
    }
    
    add_action( 'elementor/theme/register_conditions', function( $conditions_manager ) {
        require_once MYPLUGIN_PATH . 'includes/class-myplugin-logged-in-condition.php';
        $conditions_manager
            ->get_condition( 'general' )
            ->register_sub_condition( new MyPlugin_Logged_In_Condition() );
    } );
    ```
    
    **Custom condition with role selector:**
    ```php
    class MyPlugin_User_Role_Condition extends \ElementorPro\Modules\ThemeBuilder\Conditions\Condition_Base {
    
        public static function get_type(): string { return 'general'; }
        public function get_name(): string        { return 'myplugin_user_role'; }
        public function get_label(): string       { return esc_html__( 'User Role', 'myplugin' ); }
        public function get_all_label(): string   { return esc_html__( 'All Roles', 'myplugin' ); }
    
        protected function register_sub_conditions(): void {
            global $wp_roles;
            $roles = [];
            foreach ( $wp_roles->roles as $slug => $role ) {
                $roles[ $slug ] = translate_user_role( $role['name'] );
            }
            $this->add_control( 'myplugin_role_select', [
                'section' => 'settings',
                'label'   => esc_html__( 'Role', 'myplugin' ),
                'type'    => \Elementor\Controls_Manager::SELECT,
                'options' => $roles,
            ] );
        }
    
        public function check( $args ): bool {
            $required_role = $args['id'] ?? '';
            if ( ! $required_role || ! is_user_logged_in() ) return false;
            $user = wp_get_current_user();
            return in_array( $required_role, (array) $user->roles, true );
        }
    }
    ```
    
    > **Programmatic condition assignment (internal — use only for one-time migration scripts):**
    > ```php
    > update_post_meta( $template_id, '_elementor_conditions', [
    >     [ 'name' => 'general', 'id' => '', 'sub_name' => '', 'sub_id' => '' ],
    > ] );
    > delete_transient( 'elementor_pro_theme_builder_conditions' );
    > ```
    
  • field-notes.md 58.4 KB
    # Field Notes — Hard-Won Production Gotchas
    
    > **When to read this file:** Alongside the topic files whenever building a custom widget, plugin,
    > or wp.org-bound work — this is the "why it actually breaks" layer. Every rule exists because the
    > opposite **shipped and broke something**; these are the bugs that pass `php -l` / `node --check`,
    > survive code review, and only surface on a live page or in a wp.org submission.
    
    Lessons distilled from shipping real WordPress/Elementor plugins (auth forms, an embedded
    exam app).
    
    ---
    
    ## 1. Elementor widget lifecycle — the fatal & the silent
    
    - **An untyped override of a typed parent method is a site-down fatal.** Elementor declares
      `has_widget_inner_wrapper(): bool`, `is_dynamic_content(): bool`, `get_categories(): array`,
      etc. Overriding one **without the matching return type** is a PHP fatal that white-screens
      **every page** (these run on `wp_enqueue_scripts`). `php -l` checks syntax only — it does
      **not** catch a signature mismatch. Always copy the parent's return type exactly, and
      instantiate the widget against a **typed `\Elementor\Widget_Base` stub** before shipping to
      catch it at class-declaration time.
    - **`render()` runs on EVERY control change in the editor** — dozens of times per session. Any
      `add_filter()` / `add_action()` you set *inside* `render()` must be removed immediately after
      (add → render → remove, request-scoped). Leaked anonymous-closure filters stack up across
      re-renders, producing duplicated/mangled output and "undefined index" notices that crash the
      preview iframe.
    - **`get_name()` is sticky — renaming it breaks every existing page.** The widget name is stored
      as `"widgetType"` inside each page's `_elementor_data` JSON. Change it and every placed
      instance renders as "widget not found." When refactoring/prefixing, **keep `get_name()`** and
      rename only the PHP class. If you truly must rename it, migrate the stored `_elementor_data`
      tokens **and** the `frontend/element_ready/<name>.default` hook names in JS together.
    - **Pre-register asset handles before declaring them.** Handles returned from
      `get_style_depends()` / `get_script_depends()` must already be `wp_register_style/script`-ed
      (on `wp_enqueue_scripts`) or Elementor's dependency enqueue **silently no-ops** — your CSS/JS
      never loads and there's no error.
    - **Registration plumbing is guard-heavy on purpose.** Register widget *instances* on
      `elementor/widgets/register`; register the category on
      `elementor/elements/categories_registered`. Gate `require_once` with
      `did_action('elementor/loaded')` **and** `class_exists('\Elementor\Widget_Base')`. Wrap
      optional group controls (e.g. `Group_Control_Box_Shadow`) in `class_exists()` for
      cross-version safety. (If preload/opcache/static-analysis chokes on `\Elementor\*` type hints
      in a hook callback, drop the hint and verify with `class_exists()` inside instead.)
    - **Detect companion/sibling plugins at RUNTIME, never at file-load.** Plugins load in
      alphabetical order, so a constant/class another plugin defines may not exist yet when your file
      is parsed — a file-scope `defined()` check silently returns false for any plugin that sorts
      after yours. Check `defined()` / `class_exists()` from `plugins_loaded` (or later), inside the
      behaviour that needs it.
    - **A new UI section inside a widget MUST ship its own Style controls (Golden Rule #6).** Adding
      markup with CSS-only and no controls means its heading/colours inherit the theme with no way
      to change them — exactly the "I can't style this" complaint. Every new section needs a matching
      control group (heading colour/typography/alignment, body text, bg/border, links), and any new
      button must reuse an already-controlled class or get its own controls.
    
    ---
    
    ## 2. `content_template()` escaping — the editor-XSS that wp.org rejects
    
    > ⚠️ **Use `{{ }}` (escaped) for user settings — NOT `{{{ }}}`.** This corrects the instinct to
    > reach for triple-brace "to avoid double-encoding."
    
    - **`{{{ settings.* }}}` on a user-controlled value is an editor-context XSS** and **wp.org plugin
      review rejects it** (real rejection on `WordPress.Security.EscapeOutput`). `{{{ }}}` injects raw
      HTML into the Backbone preview; `{{ }}` runs it through `_.escape()`. Escaped output renders
      **correctly** in the browser — `O'Reilly` → `O&#x27;Reilly` displays as `O'Reilly` (the entity
      decodes on render). There is **no apostrophe corruption**; that was a myth.
    - **Rule:** `{{ }}` for ALL interpolated settings — titles, labels, button text, plain
      `TEXT`/`TEXTAREA`. Reserve `{{{ }}}` **only** for Elementor-generated HTML that is not raw user
      input: `{{{ iconHTML.value }}}` from `elementor.helpers.renderIcon( ... )`, and processed
      media. Even inside HTML **attributes**, use `{{ }}` — a triple-brace in an attribute breaks the
      attribute.
    - **Plugin Check flags the PHP `echo $this->method()` wrapper, not the brace.** A method that
      *returns* a built HTML string which is then `echo`'d is always flagged. Fix the **source**:
      make the method echo internally (literals + `esc_*`) and call it as a bare statement. A
      `phpcs:ignore … "escaped during construction"` here **hides** the real `{{{ }}}` issue —
      reviewers grep for exactly those justification phrases.
    
    ---
    
    ## 3. Output escaping & the wp.org "escape on output" review
    
    - **Escape LATE, at the point of output — building a value "safely" earlier does not count.**
      Every `$`-variable, option, and generated value is escaped where it is echoed, with the
      context-appropriate function (`esc_html` / `esc_attr` / `esc_url` / `esc_textarea` /
      `wp_kses` / `wp_kses_post`, or the `esc_html__()` / `esc_html_e()` i18n variants).
      Source: developer.wordpress.org/apis/security/escaping/
    - **Plugin Check flags `echo $var` / `echo $this->method()` / `echo func()`** — it does **not**
      flag string literals or `esc_*()` / `wp_kses*()` calls. Fix a flagged echo by wrapping the
      dynamic part, or by restructuring so the echo is literals + escapers only.
    - **`phpcs:ignore` is only for genuine false positives with a true, verifiable reason** (e.g.
      "static inline SVG, no user input"). Never use it to silence a real finding — reviewers
      literally grep for the justification text. When in doubt, fix the source.
    - **Justified, unavoidable false positives that DO get a tagged ignore:**
      - `NonPrefixedHooknameFound` on **core** hooks you fire/filter — `do_action('wp_login', …)`,
        `apply_filters('login_redirect', …)`, `login_enqueue_scripts`, and third-party cache hooks
        you must match by exact name.
      - `NonPrefixedConstantFound` on cache opt-out signals — `DONOTCACHEPAGE`, `DONOTCACHEOBJECT`,
        `DONOTMINIFY`, `DONOTROCKETOPTIMIZE` (each behind `! defined()`); they MUST match the exact
        names cache plugins check, so they can't be prefixed.
    - **Inputs too:** `wp_unslash()` then sanitize on **every** superglobal read — including
      `$_SERVER`: `sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? '' ) )`. Plugin
      Check raises `MissingUnslash` + `InputNotSanitized` otherwise.
    - **Vendored libraries: don't edit them, disclose them.** A bundled MIT lib that trips dozens of
      `EscapeOutput`/`AlternativeFunctions` errors stays unmodified (so it keeps updating cleanly);
      exclude its directory from your Plugin Check run and note it in the review reply, *provided* its
      output is never echoed (e.g. all its exceptions are caught internally).
    
    ---
    
    ## 4. CSS architecture — the rules that bite inside Elementor
    
    - **Never style on bare ARIA attributes.** `[aria-hidden="true"] { display:none }` (e.g. to hide
      a honeypot) also hides required-field asterisks and decorative SVGs. Give the element its own
      class (`.myplugin-hp`) and style that. Found only by inspecting **computed styles in a live
      DOM** — screenshots "looked fine."
    - **CSS custom properties cascade parent → child only — never upward.** In Elementor the markup is
      often `.myplugin-wrap > .elementor-widget… .myplugin` and the **outer wrapper does not carry
      your root class**, so a rule on the wrapper that reads `var(--myplugin-card-bg)` resolves to
      nothing if the variable is defined only on the inner element. Declare every `--myplugin-*`
      variable on a selector that **includes the wrapper** (`.myplugin, .myplugin-wrap { … }`) or
      provide a `var(x, fallback)`. Reproduce by rendering an actual Elementor **widget**, not just a
      shortcode.
    - **A `min-width` floor on a flex child is a footgun.** A flex child holding unbreakable user
      data (a long email, a URL) with `flex:1; min-width:180px` cannot shrink below 180px → it
      overflows and collides with the next column. Flex children that hold user data get
      **`min-width:0`** (so they can shrink) + **`overflow-wrap:anywhere; word-break:break-word`** (so
      the string wraps).
    - **Body-appended UI escapes your scoped token container.** A modal/popup mounted via
      `document.body.appendChild` sits **outside** your `.myplugin-scope`, so its `var(--accent)` /
      `--fs-*` / fonts resolve to nothing and it renders unstyled. Give body-mounted UI its **own
      token block** (`.myplugin-modal-overlay { …same tokens… }`). Don't fix it by moving the node
      inside the scope — that subjects it to the scope's element resets. (Relevant to off-canvas
      panels, lightboxes, and any `position:fixed` overlay — see `offcanvas-ui.md`.)
    - **Style controls must target the OUTERMOST wrapper the widget owns.** Targeting an inner element
      leaves the title/border/card outside the styled area. Pair with the `{{WRAPPER}}` selector
      rules in `js-css-standards.md`.
    - **`!important` in YOUR OWN stylesheet silently kills the matching Elementor controls.** The usual
      warning is about `!important` vs *themes*; this is the reverse and much easier to ship. A rule
      written to neutralise a default look —
      ```css
      /* "this layout is a container, not a card — don't double-card it" */
      .myplugin-form-account,
      .myplugin-wrap:has(.myplugin-form-account){background:none!important;border:0!important;
        border-radius:0!important;padding:0!important;box-shadow:none!important}
      ```
      — also outranks the CSS Elementor generates, because Elementor's output carries no `!important`.
      In one case that single line disabled **five** controls at once (Background, Padding, Border,
      Radius, Shadow) on one widget while the same controls worked everywhere else. Nothing errors;
      the panel just does nothing.
      **The `!important` is almost never needed** — check the specificity first. Here
      `.myplugin-wrap:has(.myplugin-form-account)` is **(0,2,0)** and appears later in the file, so it
      already beat the base `.myplugin-wrap, .myplugin-form` rule at **(0,1,0)**; Elementor's selector
      is **(0,3,0)+** and wins over both. Deleting the five `!important`s preserved the default look
      *and* restored the controls. Rule of thumb: **a "reset/neutralise" rule on an element that also
      has style controls must win by specificity or order, never by `!important`.**
      Symptom to recognise: *"this one control does nothing on this one widget."* Verify by reading
      **computed** styles in a live DOM and listing which rules actually match the element —
      `[...document.styleSheets].flatMap(s=>[...s.cssRules]).filter(r=>r.selectorText&&el.matches(r.selectorText))`
      shows the `!important` rule immediately.
    - **Two control groups whose selectors overlap: at equal specificity the one registered LAST wins.**
      Elementor emits generated CSS in control-registration order, so a generic group (say "Action
      Links" → `.myplugin-link-button`) registered after a specific one ("Remove Link" →
      `.myplugin-item-remove`) will override it whenever an element carries **both** classes — the
      specific control silently loses. Don't reorder sections to fix it (fragile, and the panel order
      is a UX decision). **Scope the more specific control one level deeper** so it wins on
      specificity regardless of order: `{{WRAPPER}} .myplugin-panel .myplugin-item-remove`. Audit for
      this by listing every selector your controls target and checking for shared classes.
    - **`{{WRAPPER}}` selectors can't cross-reference another control's value.** A combined visual
      effect (e.g. focus-glow spread *and* colour) needs **two** controls feeding one selector. Use
      `CHOOSE` + `selectors_dictionary` for non-numeric CSS toggles (not `SELECT`). URL controls
      return an **array** (`['url' => …, 'is_external' => …]`), not a string — read `['url']`.
    - **A COLOUR control with a `default` can never be switched off.** Elementor emits a control's
      `default` exactly like a user-set value, so `'default' => '#6B4C9A'` paints that colour on every
      install and clearing the swatch just restores it. Users read that as "the widget forces a colour
      on me." **Elementor core carries no `default` on colour controls at all** — check
      `includes/widgets/heading.php`: `title_color` uses `'global' => ['default' => Global_Colors::COLOR_PRIMARY]`
      (a *reference* the user can clear) and `title_hover_color` is **completely empty — no default, no
      global**. Empty ⇒ nothing emitted ⇒ the stylesheet's resting value stands. So: **resting look
      lives in your CSS; state colours (hover / active / selected / current) are empty controls.** Two
      consequences that are easy to miss:
      - **State colours must write a direct CSS property, never a custom property.** `--my-btn-bg:{{VALUE}}`
        read back as `var(--my-btn-bg, red)` means the fallback paints whenever the control is empty —
        "default" becomes "red", not "off". `'{{WRAPPER}} .btn:hover' => 'background:{{VALUE}};'` emits
        *no rule at all* when empty, which is the only way default means off.
      - **Then your stylesheet must not declare that state either.** Leaving
        `.btn:hover { background: var(--my-btn-bg, rgba(255,255,255,.5)) }` in the CSS re-introduces the
        same problem from the other side. Keep hover/selected rules **colour-free** — carry the state
        with `font-weight` or an inset highlight so the control genuinely owns every colour.
    - **Theme form-control selectors outrank your bare classes.** `input[type="search"]:focus` is
      **0-2-1** (element + attribute + pseudo-class); your `.my-search:focus` is **0-2-0** — the theme
      wins. Astra ships exactly that rule and repaints focused inputs its own blue, which then persists
      until blur and reads as a bug in *your* widget. Same trap on `input[type="range"]::-webkit-slider-thumb`
      (0-1-1 beats `.my-range::-webkit-slider-thumb` at 0-1-0). Fix by **element-qualifying every
      interactive-control selector** — `input[type="search"].my-search:focus` (0-3-1),
      `.my-widget button.my-chip:hover` — which still sits far below Elementor's generated
      `.elementor-{id} .elementor-element.elementor-element-{id} .sel` (0-4-0+), so the controls keep
      winning. Reach for `!important` only after this fails; it would also lock the user's controls out.
    - **Elementor's GLOBAL KIT styles bare elements, and it beats your bare class.** Separate from the
      theme trap above: the active kit emits `.elementor-kit-{id} h2`, `.elementor-kit-{id} button`,
      `… input`, `… select` at **0-1-1**, so `.my-widget__title` (0-1-0) loses and your heading takes
      the kit's size, not yours. Measured on a dialog inside a widget: the heading resolved to **24px**
      and the buttons to **22px** against the ~15px the stylesheet asked for — which then wrapped a
      two-word button label onto two lines and read as "my CSS is being ignored". Element-qualify the
      same way as for the theme — `.my-panel h2.my-panel__title`, `.my-panel button.my-panel__copy`
      (0-2-1) — still far below the 0-4-0 a control writes. Diagnose it by listing every rule that
      matches the element and sets the property, rather than assuming your class won.
    - **`rem` is right for TYPE and wrong for a TAP TARGET.** The rule above still holds for text, but
      a 44px minimum touch target written as `2.75rem` inherits whatever the theme does to the root font
      size — and plenty of themes shrink it on small screens. Measured on one: the icon buttons came out
      **44x44 on desktop and 40x40 at 375px**, i.e. below the minimum *precisely* where fingers are used
      and nowhere else. Nothing in the editor shows it, because the desktop preview is correct. Write
      physical minimums in **px** (`var(--x-box, 44px)`), keep the token so a control can still override
      it in any unit, and check the computed box at a mobile width rather than trusting the desktop
      number.
    - **Size a widget's own UI in `rem`, never `em`.** `em` multiplies whatever the page inherits, and
      a theme or kit that sets a large base silently scales your whole component: `font-size: 0.95em`
      intended as ~15px computed to **22px** because the page's base was 23px. A panel or dialog should
      set its own base (`font-size: 0.95rem`) and size its children in `rem` too, so it is predictable
      on any theme while still following the reader's root size.
    - **A `::before` scrim on an element that establishes a stacking context paints OVER it, not
      behind.** Within a stacking context the paint order is: the element's own background and borders
      FIRST, then negative-`z-index` children. So a popup styled
      `.panel { position: fixed; z-index: 10; transform: … }` with
      `.panel::before { position: fixed; inset: 0; z-index: -1; background: rgba(0,0,0,.45) }` tints
      **the panel itself** — the card renders grey with washed-out controls and it looks like a colour
      bug, not a layering one. A backdrop must be a **sibling element** one layer below, never a
      pseudo-element of the thing it is meant to sit behind.
    - **`overflow: hidden` on a card clips any popover you position inside it.** A hero/card that clips
      its image to a rounded corner also clips an absolutely-positioned dropdown, share panel or
      tooltip rendered within it — measured: a 164px panel inside a 229px hero, cut off at the card's
      edge. `position: fixed` escapes an ancestor's overflow, but **only if no ancestor establishes a
      containing block**: any `transform`, `filter`, `perspective`, `backdrop-filter`,
      `will-change: transform` or `contain: paint` on the way up re-anchors the fixed element and the
      clipping returns. Walk the ancestor chain and check for those before relying on it.
    - **`container-type: inline-size` can inflate the widget wrapper to tens of thousands of pixels.**
      Inside Elementor's nested flex containers (`.e-con` → `.e-con-inner` → `.elementor-element`),
      Chromium computed the widget wrapper at **62,926px against 4,867px of real content** — ~58,000px
      of blank page below the widget — but **only at narrow viewports**; desktop was exact. The
      giveaway is a parent taller than its only child. Bisect by setting `container-type: normal`
      inline: if the height collapses, that is it. Nothing else fixed it — not `min-height: 0`,
      `display: flow-root`, `contain-intrinsic-block-size`, `align-self: stretch`, nor removing the
      flex context from the wrapper — and neither the `@container` rules nor the `cqi` units were
      involved (neutralising both changed nothing). Unless you genuinely need the widget to respond
      to *its own* width rather than the viewport's, prefer a plain `@media` query at Elementor's
      breakpoint (767px mobile / 1024px tablet) and a `vw`-based `clamp()` instead of `cqi`.
    - **Guard independent registrations independently.** A CPT-registration function that bails with
      `if ( post_type_exists( $slug ) ) { return; }` — so a page-builder plugin like CPT UI can stay
      the owner — will also skip every `register_taxonomy()` call after it. On every site where the
      other plugin owns the post type the taxonomy is then never registered at all, and each
      `wp_get_object_terms()` returns `WP_Error( 'invalid_taxonomy' )`. Devastating right after a
      migration that moved terms onto that taxonomy. Guard each registration with its own
      `post_type_exists()` / `taxonomy_exists()` check.
    - **Renaming a taxonomy is one column.** `UPDATE wp_term_taxonomy SET taxonomy = 'new' WHERE
      taxonomy = 'old'` is the whole migration: `wp_term_relationships` points at `term_taxonomy_id`,
      which does not change, so every object keeps exactly the terms it had. Follow with
      `clean_taxonomy_cache()` for both names (term caches key on the taxonomy name, so the old one
      lingers as a phantom) and a rewrite flush if the archive slug changed.
    - **`accent-color` on a range input is dead the moment a theme sets `appearance: none`.** It only
      tints *native* track/thumb rendering, so a control that writes `accent-color` is inert and the
      slider renders as a bare rectangle. Style the parts explicitly and element-qualified:
      `::-webkit-slider-runnable-track` + `::-webkit-slider-thumb` (needs its own
      `-webkit-appearance:none` and a `margin-top` to re-centre it on a thin track) and
      `::-moz-range-track` / `::-moz-range-progress` / `::-moz-range-thumb`. WebKit has **no**
      `::-moz-range-progress` equivalent — draw the filled portion as a background sized from a
      `--pct` custom property the script updates on `input`. Keep the input itself at a 44px hit
      height with a transparent background and let the visible track be thin.
    
    ---
    
    ## 5. Embedding a standalone app as an Elementor widget
    
    - **Render inline — never in a cross-document `<iframe>`.** Elementor's style controls inject CSS
      onto the **parent** page; that CSS **cannot cross into a cross-document iframe**, so
      "controls on the toolbar style the app" is impossible with an iframe. Iframe embedding is a
      dead end for anything you want Elementor-styleable.
    - **Inline means the theme's and Elementor's CSS bleed IN, not just out.** Elementor's frontend
      rules (`.elementor img { height:auto }`) and the active theme **out-specify** your single-class
      selectors → giant logos, broken layout. Boost specificity by **doubling your root class**
      (`.myapp.myapp .thing`, specificity (0,2,…) beats `.elementor .thing`), add defensive resets,
      and scope variable-setting (theme) controls to `{{WRAPPER}} .myapp-scope`.
    - **Per-element control selectors must out-specify your boosted base.** If base rules are
      `.myapp.myapp .x` (0,3,0), a normal `{{WRAPPER}} .x` control selector (0,2,0) **loses** and the
      control does nothing — target `{{WRAPPER}} .myapp.myapp .x`. Engine-rendered **inline**
      `style="color:…"` can't be overridden by any control — don't expose controls for those values.
    - **`container-type: inline-size` + a non-stretch flex parent collapses to a sliver.** A
      size-containment element has zero intrinsic width; an `align-items:center` parent shrink-wraps
      it to nothing. Default widget wrappers to `width:100%` and centre via a Max-width control, not
      the container's alignment. Use container units (`cqw`) instead of `vw` so the widget responds to
      its own width, not the viewport.
    - **A blanket `button {}` reset breaks native buttons and lets the theme bleed through.** Keep
      `text-align:center; line-height:normal` in the reset and harden specific buttons against theme
      rules (e.g. Astra's `button { background; color; padding }`).
    
    ---
    
    ## 6. PHP & security gotchas
    
    - **A string baked into a config array at registration time is frozen — `apply_filters()` inside
      it fires before anything can hook it.** The classic shape is a field/section definition built on
      `init`:
      ```php
      // ✗ the filter runs NOW, during registration — nothing is hooked yet
      $form->add_field( 'card_open', [
          'type' => 'html',
          'html' => '<h3>' . esc_html( apply_filters( 'myplugin_card_title', __( 'Profile', 'myplugin' ) ) ) . '</h3>',
      ] );
      ```
      Registration runs on `init`; an Elementor widget's `render()`, a shortcode, or a template part
      runs **much later**. Any `add_filter()` those add is registered *after* the value was already
      computed, so the override is silently ignored — the filter "exists", fires, and does nothing.
      The same freeze hits `__()`: the string is translated once, in whatever locale was active at
      registration.
      **Fix — defer the value, not the filter.** Store a callable and resolve it where the value is
      actually used:
      ```php
      // ✓ resolved at render time, so later-registered filters apply
      $form->add_field( 'card_open', [
          'type' => 'html',
          'html' => static function (): string {
              return '<h3>' . esc_html( apply_filters( 'myplugin_card_title', __( 'Profile', 'myplugin' ) ) ) . '</h3>';
          },
      ] );
    
      // …and in the renderer:
      $html = $field['html'] ?? '';
      if ( $html instanceof Closure ) { $html = $html(); }
      echo wp_kses_post( (string) $html );
      ```
      Symptom to recognise: *"my filter/override works for some strings on the page but not others."*
      The ones that work are echoed at render time; the ones that don't were baked at registration.
      Code review will not catch this — only rendering it will.
    - **Read cookies from `$_COOKIE`, not `$_REQUEST`.** PHP's default `request_order = "GP"` means
      `$_REQUEST` holds GET+POST only — **never cookies**. A cookie read through a `$_REQUEST`-based
      helper always comes back empty (e.g. "new device" every login). Read `$_COOKIE['name']`
      directly, then `wp_unslash()` + validate.
    - **Secrets are encrypted at rest and never re-rendered in admin HTML.** Don't store an API
      client secret as a plaintext option and echo it back into `<input value="…">` — DB-level tools
      read it in full. Encrypt at rest (e.g. AES-256-GCM keyed from wp-config salts), render an
      **empty** field with a "leave blank to keep" sanitizer, and never assume a third-party
      "redaction" layer knows your option names — verify empirically.
    - **Rate-limit on `REMOTE_ADDR` by default.** Forwarded headers (`X-Forwarded-For`, etc.) are
      trivially spoofable; trusting them by default lets an attacker rotate fake IPs past the limiter.
      Make forwarded-header trust **opt-in** via a filter, for sites genuinely behind a known proxy.
    - **Policy decisions belong on the core filter, not one handler.** A subscriber/redirect rule
      enforced only inside your own login handler is bypassed by `wp-login.php` and third-party login
      flows. Put it on the core filter (`login_redirect`, high priority) so it applies everywhere.
    - **Don't `wp_kses()` inline SVG — it lowercases `viewBox` and breaks the icon.** `wp_kses_hair()`
      lowercases every attribute name, but SVG attributes are **case-sensitive**
      (`viewBox` → `viewbox` is ignored → the icon clips). For static developer SVG, inline it as a
      **string literal** (no variable) so Plugin Check sees safe literal output; for SVG that must
      come from a function in `render()`, a justified `// phpcs:ignore … -- static inline SVG, no
      user input` is acceptable.
    - **`sanitize_text_field()` always returns a string** (even `''` for array/object input). A
      following `is_string()` guard is dead code (PHPStan `function.alreadyNarrowedType`) — drop it.
    - **Security headers need a send-once guard — and three hooks.** Other plugins send the same
      headers (X-Frame-Options, nosniff…); check `headers_list()` before sending so you never
      duplicate one already queued. Send on `send_headers` (front end) **plus** `admin_init` and
      `login_init` — `send_headers` alone never covers wp-admin or the login screen. Exception: a
      **strict** `Cache-Control: no-store, private` must overwrite whatever weaker value a
      cache/theme queued earlier — don't send-once-guard that one.
    - **Never build a regex request-filter "WAF"** that pattern-matches SQLi/XSS/XXE payloads —
      bypass-prone, false-positive-prone, breaks sites, and wp.org reviewers reject it. A platform
      plugin defends **transport + configuration**; it cannot patch other code's bugs — document
      what's out of scope instead of shipping snake oil.
    - **CSP on WordPress: only the nonce-free directives.** `object-src 'none'`, `base-uri 'self'`
      and `frame-ancestors` are deliverable; a real `script-src` policy is impractical on WordPress
      (inline scripts everywhere) — don't attempt it. Assemble the entire policy into **ONE**
      `Content-Security-Policy` header; never emit two.
    - **Web-cache deception has a buildable defense:** send `Cache-Control: no-store, private` on
      (a) logged-in responses, (b) dynamic responses served under a static-looking extension
      (`/account.css`), and (c) REST auth responses — plus define the cache-plugin bypass constants
      (`DONOTCACHEPAGE` etc.). A broken logged-in cache bypass really does serve guest-cached HTML to
      authenticated users in production.
    - **Secret redaction lives in ONE shared helper used by every output path.** Redacting an option
      name in one tool is pointless while another exit (a DB reader, an options dump, an export) can
      read the same value — route every endpoint through the same `is_secret_name()` / redaction
      functions.
    - **One-time notices: transient flash + PRG, never a URL flag.** A "saved!" notice read from
      `$_GET` survives every refresh. Set a short **user-scoped transient**, redirect to a clean URL
      (Post/Redirect/Get), then read-and-`delete_transient()` on display — it shows once, never
      again.
    
    ### Holding a login (2FA, device confirmation, terms acceptance) is not a failed login
    
    - **Returning a `WP_Error` from `authenticate` makes core fire `wp_login_failed`.**
      `wp_authenticate()` ends with an allowlist of exactly two codes —
      `array( 'empty_username', 'empty_password' )` — and fires the hook for **every** other error
      code. So the moment you hold a login for a second step, everything that listens for brute force
      counts a correct password as a failure: your own activity log, your own per-account throttle,
      and every security plugin on the site. `get_error_code()` is consulted, i.e. the **first** code
      only.
    - **The symptom is a slow poison, not a bug report.** The user signs in fine, so nothing looks
      broken; the login-activity dashboard just fills with failures for people who did nothing wrong,
      and a progressive throttle starts adding seconds after the free allowance (three, typically) is
      spent on successful sign-ins. It surfaces as *"why does my dashboard say I keep failing?"* weeks
      later.
    - **Fix by discriminating, not by silencing.** `wp_login_failed` passes the `WP_Error` as its
      **second** argument (core has done so since 5.4), but listeners registered with `accepted_args`
      of 1 never see it. Register yours with 2 and skip your own hand-off code:
      ```php
      add_action( 'wp_login_failed', 'prefix_record_failed', 10, 2 );
      function prefix_record_failed( $username, $error = null ): void {
          if ( $error instanceof WP_Error
              && in_array( 'prefix_2fa_required', $error->get_error_codes(), true ) ) {
              return; // a correct password waiting on its second step
          }
          …
      }
      ```
    - **Then count the step that really does fail.** Having excluded the hand-off, the *wrong* second
      factor is usually recorded nowhere — the dashboard ends up exactly backwards. Fire
      `wp_login_failed` yourself from the verification handler with your own error code (the
      Two-Factor feature plugin does the same), so the log, the throttle and third-party brute-force
      plugins all see the real attempt.
    - **Don't `exit` from the filter to dodge the hook.** Redirecting out of `authenticate` skips
      every filter registered at a later priority, including other plugins' post-password checks —
      you would be trading a miscount for a security hole. Return the `WP_Error` and discriminate.
    - **Audit every path, not the one you were shown.** A plugin that intercepts both its own form and
      `wp-login.php` usually handles them differently (one returns the error, one redirects), so only
      one path miscounts — and the two disagree about what happened until you fix it.
    
    ---
    
    ## 7. Transactional email (`wp_mail`)
    
    - **Email bodies are inline styles ONLY.** `wp_kses_post()` does not allow `<style>`/`<head>`, so
      it strips those **tags** but **keeps their text content**; `wpautop()` then wraps the leftover
      CSS in `<p>` and the mail client renders it as **visible body text**. WordPress's mailer also
      treats the body as an HTML **fragment** (`<html>`/`<head>`/`<style>` are not honoured, and email
      clients ignore `<style>` blocks anyway). **Strip `<style>/<head>/<script>/<link>` blocks
      (content included) and `<html>/<body>` wrappers BEFORE `wp_kses_post()`,** then style every
      element with `style="…"` attributes. Set `Content-Type: text/html` only on the custom-body path.
    
    ---
    
    ## 8. Theme Builder & editor context
    
    - **Theme Builder targets a REAL page, not a virtual post.** Plugins that serve content from a
      virtual post (`ID = -1`) never match Elementor Theme Builder conditions (Singular > Page) — no
      header/footer/template applies and you get a bare unstyled page. Create **real WP pages** for
      each action and let Elementor target them by ID; keep virtual rewrite pages only as the
      non-Elementor fallback. Create/adopt those pages idempotently on activation (see §9).
    - **Detect editor/preview context** when your code rewrites URLs or changes behaviour, so it
      stands aside in the builder. Check: `REST_REQUEST` + an `/elementor/` route, `?action=elementor`,
      `?elementor-preview`, `elementor_*` AJAX actions, and
      `\Elementor\Plugin::$instance->preview->is_preview_mode()`. Enqueue editor-only CSS on
      `elementor/editor/after_enqueue_styles`.
    - **Refreshing `--var` defaults won't show on already-customised widgets, and Elementor caches
      per-page CSS.** A new default only appears where the user hasn't set an Elementor value, and not
      until **Elementor → Tools → Regenerate Files & Data** + a hard refresh. Tell the user this
      instead of assuming the change is broken.
    - **The document a widget lives in is NOT the post being viewed — never use
      `get_queried_object_id()` to identify your own widget.** On a Theme Builder template the
      settings are in the template's `_elementor_data`; the queried object is whichever post the
      template is currently rendering (and on an archive it isn't a post at all). Any code that looks
      the widget up by the queried ID — an AJAX endpoint, a REST route, a cache key — finds nothing
      and fails on **every** theme template while working perfectly on ordinary pages. Ask Elementor
      instead, inside `render()`:
      ```php
      $doc = \Elementor\Plugin::$instance->documents->get_current();
      $id  = $doc ? (int) $doc->get_main_id() : (int) get_the_ID();
      ```
      `Frontend::get_builder_content()` calls `switch_to_document()` before rendering elements for
      exactly this reason. Use `get_main_id()`, not `get_id()`, so revisions and autosaves resolve to
      the parent.
    
    ---
    
    ## 9. Data safety — activation & uninstall
    
    - **Uninstall must not delete the user's content.** Only delete pages/posts your plugin created
      (flagged with your own `_myplugin_auto_created` meta), that are **unedited** (no
      `_elementor_edit_mode`), and **empty**. Deleting "by stored ID" without provenance checks wipes
      pages the user later customised.
    - **Activation is adopt-or-create, idempotently.** Never duplicate an existing plugin page, never
      overwrite user edits. Re-running activation (or an update) must converge, not multiply.
    - **Rewrite-rule changes need a permalink flush** — a new action slug 404s until rules regenerate.
      Flush on activation, and on first load after an update; don't rely on the user visiting
      Settings → Permalinks.
    - **Managed marker blocks (`# BEGIN MyPlugin` … `# END MyPlugin`) need line-anchored strip
      regexes.** If one marker name is a string **prefix** of another ("MyPlugin" vs "MyPlugin
      Hardening"), an unanchored regex matches the wrong block and swallows content between them.
      Anchor the marker lines (`[ \t]*\R` after BEGIN, `[ \t]*(?:\R|$)` after END) — and remove
      EVERY block you wrote on deactivation, not just the first.
    
    ---
    
    ## 10. Build, versioning & wp.org distribution
    
    - **`languages/` ships the `.pot` and nothing else.** Compiled `.mo` / `.l10n.php` files inside a
      wp.org-hosted plugin never load (the loader only scans `WP_LANG_DIR/plugins/`) and the call that
      would load them is flagged by Plugin Check. Keep author-side catalogues **outside** the plugin
      folder — e.g. a repo-level `translations/` dir excluded from the zip allowlist. Full mechanism
      and the translate.wordpress.org workflow: `wordpress-apis.md` §6.
    - **A "sync to test install" step that reports success can copy nothing.** On Windows, calling
      `robocopy` from a POSIX shell with **forward-slash paths** makes it read `/Users/...` as
      switches: it prints its usage text, copies zero files, and **still exits 0**. Everything
      downstream then tests stale code while every check passes. Use `cp -r` from that shell (or
      backslash paths from PowerShell), and afterwards **assert a known-new symbol exists in the
      destination** rather than trusting the exit code.
    - **Splitting public/private repos: don't let a "sync" overwrite the public page.** A common
      layout is a private source repo plus a public docs/Pages repo. The published `index.html`
      usually carries things the private copy lacks — canonical/OpenGraph tags, and a download button
      pointing at **wordpress.org** rather than a GitHub archive. Port the *content* change (version
      string, "What's New") into the public copy; never overwrite it wholesale, or you ship a broken
      download link. Untrack the duplicate in the private repo so there is one source of truth.
    - **Release order:** publish to SVN first, confirm the directory serves the new version, *then*
      update the public page — so the site never advertises a version users cannot download.
    - **The distributable zip is an allowlist, not the repo.** Copy ONLY runtime dirs/files
      (`includes/ admin/ assets/ languages/` + `readme.txt uninstall.php <main>.php`) into a clean
      staging folder named exactly as the slug, then compress. Everything else (`.git/ .github/
      CLAUDE.md README.md *.html graphify-out/ wporg-assets/ Screenshots/ dotfiles`) must stay out, or
      Plugin Check flags stray/hidden/bad-name files. Verify the staged tree has no dotfiles before
      zipping.
    - **A version bump touches every surface in lockstep:** main-file header `Version:` + the
      `MYPLUGIN_VERSION` define; `readme.txt` `Stable tag` + `== Changelog ==` + `== Upgrade Notice ==`
      (each notice < 300 chars); `README.md` changelog; and any landing `index.html` download
      button/title. After bumping, **grep all files for the old version** — a stuck `index.html` is
      easy to miss.
    - **Only bump the version when front-end assets change.** A server-only fix (data/answer-key,
      back-end logic) needs no bump — bumping forces every visitor to re-download all assets for
      nothing. Conversely, CSS/JS changes are **invisible without** a version bump (it is the
      enqueue cache-buster).
    - **Every stylesheet edit must hit BOTH `.css` and `.min.css`.** Production enqueues the minified
      file (the unminified one only under `SCRIPT_DEBUG`), so a change applied only to `.css` ships
      invisible. After editing, grep **both** files for the new selector, then bump the version
      constant.
    - **Verify the zip's internal path separators are FORWARD slashes.** Windows PowerShell 5.1's
      `Compress-Archive` writes **backslash** entry names (`slug\main.php`); Linux treats `\` as an
      ordinary filename character, so WordPress extracts flat files with no plugin folder →
      **"plugin does not exist"** on upload. Pack with a .NET 5+ tool (pwsh 7+) and, after packing,
      assert an entry named `slug/main-file.php` (forward slash) exists. On a cloud-synced folder
      (OneDrive/Dropbox) a just-written zip can briefly vanish mid-sync — re-create and re-verify if
      it disappears.
    - **Screenshots, banner, and icon are wp.org SVN `/assets/` files — NOT part of the plugin zip.**
      Name screenshots `screenshot-1.png`, `screenshot-2.png`, … each with a matching caption in
      `readme.txt == Screenshots ==`. Banner (`banner-772x250` / `banner-1544x500`) and icon
      (`icon-128x128` / `icon-256x256`) are listing graphics, also assets-only. Re-check listing
      graphics after any **rename** — an old-brand banner survives unnoticed across releases.
    - **Run Plugin Check (the wp.org reviewer's own tool) before every resubmission**, excluding any
      vendored-lib directory, until **your** code is 0 findings.
    - **The directory runs on SVN, not git.** The release itself — checkout, copy the built tree into
      `trunk/`, `svn cp trunk tags/X.Y.Z`, commit, and manage `/assets/` — is a Subversion workflow.
      Full commands + a WordPress.org worked example are in the **`svn/`** sub-bundle (`svn/svn.md`).
    
    ---
    
    ## 11. Verification reality
    
    - **A scripted patch anchored on a bare `function foo(` signature STEALS that function's docblock.**
      Inserting new code immediately before a function signature puts the new function *between* the
      existing docblock and the function it documents: the old function is left undocumented and the new
      one wears two docblocks stacked. `php -l` passes, the code runs, and every structural grep is
      happy — only PHPCS (`Squiz.Commenting.FunctionComment.Missing`) sees it. It happened three times in
      one change because the same anchoring style was reused. Anchor on the **docblock opener** (`/**\n
      * The thing.`) rather than the signature, or assert afterwards that the file contains no `*/`
      immediately followed by `/**`.
    - **A `case` block that references the wrong variable fails SILENTLY and shaped like success.**
      `substr( $key, … )` inside a function whose parameter is `$field` yields `''`, so an array lookup
      built from it returns `''` and every placeholder rendered empty — a warning in the log and nothing
      visibly broken. `php -l`, a call-type checker and every "is the case block present?" grep all pass,
      because the block *is* present. Only rendering the markup with `display_errors` on showed it.
      Assert on the **resolved value** (`attrs['placeholder'] === $expected`), never on the presence of
      the code that computes it.
    
    - **`php -l` / `node --check` prove syntax only.** They do **not** catch typed-signature
      mismatches (a fatal — see §1), missing/ineffective controls, leaked filters, or any runtime
      behaviour. Stub-test against a **typed `\Elementor\Widget_Base`** (instantiate + run
      `register_controls()` via reflection + exercise `render()` for each user state) before shipping.
    - **You usually cannot see visual bugs from static checks.** Logo size, button placement, a
      sliver-collapse, a colliding flex column, a `var()` that resolved to nothing — all pass lint and
      "look fine" in code. Verify on a **live render** (the WP MCP bridge / a browser-driven preview),
      inspecting **computed styles**, not just screenshots.
    - **A `<button>` inside your widget is a theme's button.** Themes style bare `button:hover` /
      `button:focus` with the site accent (Astra does, at **0-1-1**), which outranks your single class
      — so the one control in a row that happens to be a button flips colour on hover and then *stays*
      that colour on focus after it is clicked, while the labels and links beside it do not. Elementor
      control CSS does not save you: it only ever targets the resting state. Fix on both sides — put
      `:hover, :focus` into the control's own `selectors` so a chosen colour reaches those states, and
      restore the resting look in your stylesheet at a specificity above the theme's.
    - **`display: var(--display)` with the variable unset computes to `inline`, not `block`.** An
      Elementor container whose generated CSS did not load loses its `--display`, the declaration is
      invalid at computed-value time, and `display` falls back to its *initial* value. The container
      then generates a line box, so it looks like "a small mystery gap above my first section" rather
      than a stylesheet that failed to load. Check `getComputedStyle(el).getPropertyValue('--display')`
      before chasing the spacing.
    - **PHPStan on an Elementor plugin needs two config lines, or it argues for deleting your guards.**
      Point `scanDirectories` at the real Elementor source (a hand-written stub cannot catch signature
      drift), then:
      - add a `bootstrapFiles` entry `define()`-ing your plugin constants — PHPStan does not evaluate
        the `define()` calls in your bootstrap, so every use reads as `constant.notFound` and buries
        the real findings (14 of 33 on one run);
      - set **`treatPhpDocTypesAsCertain: false`**. Elementor declares its managers as *untyped*
        properties carrying only a `@var` docblock (`public $preview;`) and they are genuinely null
        until it initialises them. Left on, PHPStan calls every defensive `isset()` redundant and
        every version_compare against the scanned Elementor "always false" — i.e. it recommends
        removing the exact checks that prevent a fatal.
      What survives that is worth reading. **Run level 8 once even if you gate at 5**: past the
      `missingType.iterableValue` noise it found `foreach ( glob( … ) )` — `glob()` returns `false` on
      failure — and a sanitiser returning `preg_replace()`'s result, which is `null` when the engine
      gives up.
    - **Plugin Check warnings are not errors, and some are the sniff reading an array key.**
      `'meta_value' => __( 'Custom field' )` in a SELECT control's options trips
      `SlowDBQuery.slow_db_query_meta_value`; `'exclude' => __( 'Exclude sticky posts' )` trips
      `PostNotIn_exclude`. Neither is a query argument. Those earn a tagged `phpcs:ignore`; a real
      `post__not_in` excluding a bounded, user-chosen set does not — leave it and say why.
    - **wp.org screenshots can be produced headlessly, including the editor panels.** `chrome
      --headless --screenshot` only loads a URL and snaps it, which is enough for the front end but
      cannot select a widget. Drive Chrome over the DevTools Protocol instead — Node 21+ has a global
      `WebSocket` and `fetch`, so it needs no packages: launch with `--remote-debugging-port`, read
      `/json` for the target, then `Page.navigate` / `Runtime.evaluate` / `Page.captureScreenshot`.
      Four things bite, in order:
      - **`--screenshot` needs an ABSOLUTE path.** A relative one writes nowhere and reports nothing.
      - **The editor is behind `auth_redirect()`**, which reads `$_COOKIE` directly. A shim that calls
        `wp_set_auth_cookie()` and lets the same request continue does nothing; it must set the cookie
        and then **redirect**, so the cookie is present on the next request. No password is typed.
      - **Headless loses the preview-iframe load race**, leaving the editor on its splash with
        everything underneath already loaded. Call `elementor.onPreviewLoaded()` yourself, hide
        `#elementor-loading`, and force `#elementor-preview` visible.
      - **Select the widget with `$e.run('document/elements/select', { container })`** rather than a
        synthetic click, then wait ~10s — selecting re-renders the preview, and capturing too early
        gives an empty canvas.
      Cards below the first row are lazy-loaded and a screenshot cannot scroll, so a demo-only filter
      swapping `loading="lazy"` for `eager` is what makes lower rows appear.
    - **Never assert "my query matches the archive" against `$wp_query->posts`.** That is the
      archive's *first page*, so a widget asked for 100 per page legitimately returns more and the
      assertion fails on correct code — which then sends you refactoring a query that was fine.
      Compare against `found_posts`, or re-run the archive's own `query_vars` unpaged.
    - **Style-tab controls do not exist outside the editor unless you ask for them.** Elementor skips
      registering them for performance, so a harness that inspects `get_controls()` from WP-CLI reports
      **every Typography / Colour / Border / Box-Shadow / alignment control as missing** — a wall of
      confident false failures against code that is completely fine. Call
      `\Elementor\Core\Frontend\Performance::set_use_style_controls( true )` before `get_controls()`
      (this is exactly what `Core\Files\CSS\Base::parse_content()` does before rendering CSS).
    - **Group controls do not carry their selector on the control you would guess.** The
      `_typography_typography` and `_box_shadow_box_shadow_type` entries are the *type* switches and
      have no `selector`; the real one lives on `_box_shadow`, `_font_family`, `_font_size` and so on.
      Assert against a sub-control that actually has a selector, or the assertion is meaningless.
    - **When a check fails, suspect the check before the artifact.** Mis-scoped regex, byte-slicing
      multibyte UTF-8 (`head -c`/`tail -c` cuts a Georgian/emoji char mid-byte → looks like
      corruption), or a stub that flags on the wrong condition produce confident **false alarms**.
      Verify the harness (paths, encodings, regex), then the file.
    - **WP-CLI cannot test code that stands down under WP-CLI — and it fails *green*.** Auth and
      security guards routinely open with a bail for non-interactive contexts:
      `REST_REQUEST`, `XMLRPC_REQUEST`, `application_password_did_authenticate`, `wp_doing_cron()`,
      `defined( 'WP_CLI' ) && WP_CLI`. Correct code — none of those has a second factor — but it means
      the branch never executes under `wp eval-file`, and every assertion of the form *"X did not
      happen"* passes having exercised nothing. Such a test passes identically with the fix reverted.
      Before writing one, grep the function under test for those constants; if any appears, drive it
      over HTTP instead: a probe in the webroot that does `require __DIR__ . '/wp-load.php'`, fetched
      with `curl` and deleted afterwards. Print `defined( 'WP_CLI' ) ? 'yes' : 'no'` in its output so
      the transcript proves which context ran, set `$_SERVER['REQUEST_METHOD'] = 'POST'` and populate
      `$_POST` to stand in for a form submission — and pair every "did not happen" with a control that
      **does** (a wrong password that still logs and still counts).
    - **Run Plugin Check against what ships, not your working tree.** Copying the dev tree into a
      local WP makes it report `hidden_files` / `application_detected` / `unexpected_markdown_file` for
      `.gitignore`, `phpunit.xml.dist`, `CLAUDE.md` and friends — files `export-ignore` and
      `.gitignore` keep out of the package anyway. Those findings are noise that hides the real ones.
      Deploy only the shipped set (take it from the SVN trunk listing, don't retype it), empty the
      destination first, and assert nothing extra arrived.
    
    ---
    
    ## 12. Re-rendering a widget out of band (AJAX / REST) and in loops
    
    ### Before building an AJAX endpoint: consider re-rendering the real page and swapping a fragment
    
    An admin-ajax or REST handler that returns HTML is a **second renderer**, and it runs without the
    page that made it. For a panel inside a widget that is the wrong trade, because the widget's own
    settings are exactly what is missing:
    
    - **Editor text overrides are invisible to a background handler.** A widget that applies its
      Content-tab overrides by adding filters around its own `render()` (add → render → remove) has
      none of them active inside admin-ajax: there is no widget instance and no settings. The endpoint
      returns the *translated default* instead of the site owner's wording, and the panel silently
      reverts to English the first time the user touches it. The same applies to anything else
      resolved at render time — conditional enqueues, `{{WRAPPER}}` CSS, `the_content` filters, locale.
    - **The alternative: post the form to the page's own URL and swap one element.** Mark the request
      (`prefix_fragment=1`), have the handler do its work and **return instead of redirecting**, let
      WordPress render the page exactly as it always does, then client-side parse the response and
      replace one container's `innerHTML`:
      ```js
      const res  = await fetch( pageUrl, { method: 'POST', body: fd, credentials: 'same-origin' } );
      const doc  = new DOMParser().parseFromString( await res.text(), 'text/html' );
      target.innerHTML = doc.querySelector( '.prefix-panel-body' ).innerHTML;
      ```
      There is no second renderer, so the swapped markup **cannot** drift from what a reload produces.
    - **Cost is one extra page render** — for a logged-in account page that is nothing, and it buys
      every filter, override and translation for free. Weigh it against maintaining a parallel HTML
      builder forever.
    - **Carry the state flag in the request, not the URL.** The PRG path signals results with a query
      arg (`?prefix_codes=1`); with the redirect skipped there is no query string. Give the renderers
      one lookup that falls back to `$_GET`, so both paths read the same way:
      ```php
      function prefix_flash( string $key, ?bool $set = null ): bool {
          static $flags = [];
          if ( null !== $set ) { return $flags[ $key ] = $set; }
          return $flags[ $key ] ?? ( '' !== (string) prefix_get( $key, 'get' ) );
      }
      ```
    - **A helper that used to `exit` now returns — add `return;` at every call site.** PRG helpers are
      written as `prefix_redirect( … );` with no `return` after them because they never came back.
      Make one return and the code below it runs: the "invalid, bounce back" branch falls through into
      the success branch. Grep every call, not just the ones you edited.
    - **Failure handling is asymmetric, and getting it wrong repeats the action.** If the request
      never completed the server did nothing — fall back to `form.submit()`. Once a response has
      arrived the server *has* acted, so a later error must `location.reload()`, never re-post; a
      second submit regenerates recovery codes, sends a second email, charges twice. Set a flag the
      moment the response lands and branch on it in `catch`.
    - **Bind the submit handler on the container, delegated**, so it survives the swap; clear any
      "already bound" marker you keep on that container, and re-run whatever initialised the old
      markup (QR drawing, copy buttons). Guard re-entry with a `data-busy` attribute — a disabled
      button is not enough when Enter also submits.
    - **Progressive enhancement is the whole point:** the same form still posts normally with no
      JavaScript, and both paths must be tested. `curl` the form without the marker (expect `302` +
      the flag in `Location`) and with it (expect `200` and the new content in the body).
    - **`$_SERVER['REQUEST_METHOD']`-gate the marker.** A forged `GET ?prefix_fragment=1` must not
      reach a state-changing handler.
    
    - **A background request has no page.** `get_the_ID()`, `$wp_query`, `is_singular()` and the
      permalink of "the current page" are all absent inside a REST or admin-ajax handler. Any query
      option built on them — *related to the current post*, *exclude the current post*, *current
      query* — silently produces a **different result set** on page 2 than on page 1, so the visitor
      gets repeats or gaps rather than an error. Pass the displayed post's ID explicitly, validate it
      (`get_post()` + published + not password-protected), `setup_postdata()` around the render, and
      restore afterwards.
    - **Don't try to rebuild an archive's main query in a background request.** Query vars sent by the
      client are the caller defining their own query, which is the thing your endpoint exists to
      prevent; `WP::parse_request()` a second time fires `parse_request` again and other plugins
      hooked there will act on it. For a *current query* source, leave pagination as **real page
      links** — that is what Elementor's own Archive Posts widget does — and say so in a control
      description.
    - **Links rendered inside an AJAX response are built from the AJAX request.** `add_query_arg()`
      and `remove_query_arg()` with no URL argument read `$_SERVER['REQUEST_URI']`, which during the
      call is `/wp-json/...`. Middle-clicking such a link hands the visitor raw JSON. Send the page's
      URL along and validate it with **`wp_validate_redirect( $url, '' )`** — core's own "is this URL
      ours" check — rather than trusting it or inventing a regex.
    - **"No results" and "past the end" are different answers.** Rendering the empty state for any
      page with no posts means an append-mode request one page past the end drops *"no posts found"*
      underneath a grid full of results. Gate the empty state on page 1.
    - **A Load More button must stop existing, not just stop working.** Guard on
      `$paged >= $max_num_pages` and render nothing — a button that survives its last page reads as a
      stray line of unstyled text under the grid, and pressing it fetches an empty page.
    
    ### Dynamic-tag values that render as CSS cannot be looped
    
    - **Elementor deliberately keeps them out of the cached stylesheet.** A control that is both
      dynamic and CSS-producing — a container **background image** bound to the featured image is the
      usual case — is recorded in the CSS file's `dynamic_elements_ids` meta and *excluded* from
      `elementor-post-<id>.css`. It is supplied separately by
      `\Elementor\Core\DynamicTags\Dynamic_CSS`, resolved against whatever post is current.
    - **That mechanism is once-per-request and single-selector, so it cannot serve a loop.**
      `Core\Files\CSS\Base::enqueue()` records the file handle in a **static** `$printed` array and
      returns early ever after; and the selector it emits
      (`.elementor-<template> .elementor-element-<id>`) is identical for every card, so even emitted
      N times the last post would win for all N. The symptom is exact and misleading: **the image
      shows in the editor and is simply absent on the front end**, because the editor regenerates CSS
      against the current post on every render.
    - **Fix by scoping per item.** Give each card wrapper a per-post class, subclass `Dynamic_CSS`,
      override `get_element_unique_selector()` to prefix that class (it is what fills `{{WRAPPER}}`,
      so it covers every control), construct it with `new` (not `create()` — the files manager caches
      by class+args and would hand every card the first card's CSS), and `echo` the result inline.
      Elementor Pro's loop grid does the same thing with `.e-loop-item-<post_id>`.
    - **Check `dynamic_elements_ids` is non-empty before doing any of this** — almost no template
      styles anything dynamically, and that check is what stops every card paying for a second CSS
      parse.
    - **An image *widget* with a dynamic tag has none of these problems**, because it renders an
      `<img>` inline per post. If a user only needs a per-post picture, that is the cheap answer.
    
    ### Swiper inside an optimised site
    
    - **Swiper's autoplay refuses to start while the carousel measures zero, and never retries.**
      `run()` opens with `if ( ! swiper.size ) { autoplay.running = false; autoplay.paused = false;
      return; }`. Nothing calls it again, so autoplay is dead for the life of the page. The
      fingerprint is `running === false` **and** `paused === false` — no other path clears both.
    - **This is why "it works in the editor and is frozen on the live site" happens.** LiteSpeed /
      WP Rocket / Cloudflare APO defer or async the stylesheet that gives the container its width, so
      the measurement at init is 0; the editor has no such optimisation. It is a **race**, so it
      reproduces intermittently — do not conclude it is fixed because one reload autoplayed.
    - **Re-arm rather than re-init.** Watch the root with a `ResizeObserver` (plus a short interval
      fallback, since a late stylesheet can change the width without a resize entry for that element);
      once `swiper.size` is non-zero, `swiper.update()` then `swiper.autoplay.start()`. Record any
      visitor-initiated pause on the instance first, so recovery never restarts what someone stopped.
    - **Elementor's carousels ship no visible play/pause button.** That is a WCAG 2.2.2 failure, not a
      pattern to copy. Keep the control reachable by keyboard and hidden until focused — that gives
      the same clean look without removing the mechanism.
    
  • js-css-standards.md 8.6 KB
    # JavaScript & CSS Standards
    
    ## JavaScript Standards
    
    ```js
    // ✅ IIFE + ES6+ — no jQuery unless forced by a WP core dependency
    ( () => {
      'use strict';
    
      const init = ( scope = document ) => {
        scope.querySelectorAll( '.myplugin__trigger' ).forEach( el => {
          el.addEventListener( 'click', handleClick );
        } );
      };
    
      const handleClick = ( e ) => {
        e.preventDefault();
        const trigger = e.currentTarget;
        const target  = document.getElementById( trigger.getAttribute( 'aria-controls' ) );
        const isOpen  = trigger.getAttribute( 'aria-expanded' ) === 'true';
    
        trigger.setAttribute( 'aria-expanded', String( ! isOpen ) );
        // ✅ CSS class controls visibility — NOT aria-hidden (see offcanvas-ui.md for full pattern)
        target?.classList.toggle( 'myplugin__panel--open', ! isOpen );
        target?.classList.toggle( 'myplugin__panel--hidden', isOpen );
      };
    
      // ✅ Wait for 'elementor/frontend/init' before registering addAction.
      // DO NOT use if(window.elementorFrontend) — if elementorFrontend already
      // exists, all element_ready events have already fired and addAction silently
      // misses every widget already on the page.
      //
      // ⚠️ BIND WITH jQUERY — this is the bulletproof, version-agnostic pattern Elementor's own
      // docs lead with. Elementor fires 'elementor/frontend/init' through jQuery's event system
      // ( jQuery(window).trigger(...) ). jQuery-triggered CUSTOM events do NOT reach native
      // addEventListener handlers, so on Elementor < 3.5 a window.addEventListener listener
      // SILENTLY NEVER FIRES — handlers on AJAX-loaded widgets (popups, loop items, editor
      // re-renders) just die with no error. Elementor 3.5+ added dual-dispatch (jQuery + native),
      // so addEventListener also works on modern versions — but jQuery is the safe default and
      // costs nothing extra (jQuery is already a dependency of elementor-frontend).
      // Source: developers.elementor.com/native-js-events-in-elementor/
      //
      // ⚠️ This script must NOT use strategy:'defer' if it relies on this event — a deferred
      // script may execute AFTER init fires (silent miss). Use in_footer:true WITHOUT defer, and
      // declare 'elementor-frontend' (and 'jquery') as dependencies.
      jQuery( window ).on( 'elementor/frontend/init', () => {
        window.elementorFrontend.hooks.addAction(
          'frontend/element_ready/myplugin-widget.default',
          ( $scope ) => {
            // ✅ Guard against undefined scope — can happen during lazy-loaded section reveal
            if ( ! $scope || ! $scope[0] ) return;
            init( $scope[0] );
          }
        );
      } );
    } )();
    ```
    
    ---
    
    ## Enqueue API — WP 6.3+ defer/async
    
    ```php
    add_action( 'wp_enqueue_scripts', 'myplugin_enqueue_assets' );
    function myplugin_enqueue_assets(): void {
        wp_enqueue_style(
            'myplugin-css',
            MYPLUGIN_URL . 'assets/css/myplugin.css',
            [],
            MYPLUGIN_VERSION
        );
    
        wp_enqueue_script(
            'myplugin-js',
            MYPLUGIN_URL . 'assets/js/myplugin.js',
            [],                    // no jQuery unless genuinely required
            MYPLUGIN_VERSION,
            // Both keys are required: 'strategy' controls HOW the script loads (defer/async);
            // 'in_footer' controls WHERE it is placed (head vs footer). They are independent.
            // Omitting 'in_footer' => true places the script in <head> even with defer.
            // See: make.wordpress.org/core/2023/07/14/registering-scripts-with-async-and-defer-attributes-in-wordpress-6-3/
            [ 'strategy' => 'defer', 'in_footer' => true ]
        );
    
        // ✅ Pass PHP data to JS — use wp_add_inline_script (best practice since WP 4.5+).
        // wp_localize_script() was designed for i18n string localisation only.
        // wp_add_inline_script() is the correct tool for passing arbitrary data to JS.
        //
        // ⚠️ wp_json_encode() returns false|string — guard against encoding failure.
        // "const myPluginData = ;" is a JS syntax error that breaks the whole page.
        $script_data = wp_json_encode( [
            'ajaxUrl' => admin_url( 'admin-ajax.php' ),
            'nonce'   => wp_create_nonce( 'myplugin_ajax' ),
            'i18n'    => [ 'error' => __( 'Something went wrong.', 'myplugin' ) ],
        ] );
        if ( $script_data ) {
            wp_add_inline_script(
                'myplugin-js',
                'const myPluginData = ' . $script_data . ';',
                'before'  // inject BEFORE the script so it's available on load
            );
        }
    }
    ```
    
    ---
    
    ## CSS Standards (BEM + Scoped Design Tokens)
    
    ```css
    /* ─── BEM Naming ──────────────────────────────────────────── */
    .myplugin {}                          /* Block */
    .myplugin__header {}                  /* Element */
    .myplugin__trigger {}
    .myplugin__trigger--active {}         /* Modifier */
    
    /* ─── Scoped design tokens — never pollute :root ─────────── */
    .myplugin {
      --mp-color-primary:   #1a1a2e;
      --mp-color-accent:    #e94560;
      --mp-spacing-sm:      0.5rem;   /* 8px  */
      --mp-spacing-md:      1rem;     /* 16px */
      --mp-spacing-lg:      2rem;     /* 32px */
      --mp-radius:          8px;
      --mp-transition:      0.2s ease;
    }
    
    /* ─── Fluid typography — replaces breakpoint font-size overrides ── */
    .myplugin__heading {
      font-size: clamp(1.25rem, 2vw + 1rem, 2.5rem);
      line-height: 1.2;
    }
    
    /* ─── Accessible focus styles (never use outline: none without replacement) */
    .myplugin__trigger:focus-visible {
      outline: 3px solid var(--mp-color-accent);
      outline-offset: 2px;
    }
    
    /* ─── Safe Elementor overrides — CRITICAL selector rules ─────────────────
       ⚠️ NEVER target .elementor-widget-container directly.
       This inner wrapper div is REMOVED when Elementor's "Optimized Markup"
       experiment is active (introduced 3.25 alpha; opt-in in 3.35.x; enabled by default
       for new sites in Elementor 4.0). Does NOT exist in V4 Atomic Elements at all.
    
       ✅ CORRECT patterns — always use the widget root or your own BEM classes: */
    .elementor-widget-myplugin-widget { /* targets the widget root — always present */ }
    .myplugin-widget { /* your own BEM root class — always safe */ }
    
    /* ✅ {{WRAPPER}} in Elementor controls maps to .elementor-widget-myplugin-widget
       in CSS — use this for control selectors in register_controls(). */
    
    /* ─── 8pt spacing system ───────────────────────────────────── */
    .myplugin__card {
      padding:          var(--mp-spacing-md);
      margin-block-end: var(--mp-spacing-lg);
      border-radius:    var(--mp-radius);
      transition:       transform var(--mp-transition);
    }
    ```
    
    ---
    
    ## Non-Latin scripts — the typography rules that break silently
    
    A plugin in the wp.org directory gets translated into scripts you never test. Two Latin-only
    habits produce visibly broken text and never raise an error:
    
    **`text-transform: uppercase` + `letter-spacing` on UI micro-copy.** The small-caps look
    (`text-transform: uppercase; letter-spacing: .07em`) is a Latin convention. Georgian, Hebrew,
    Arabic, Thai and CJK are **caseless** — the transform is a no-op for them, but the tracking still
    applies and visibly pulls the word apart. Georgian `ან` ("or") on a form divider renders as
    loose, disconnected glyphs.
    
    ```css
    .myplugin__divider span {          /* fine for Latin */
      text-transform: uppercase;
      letter-spacing: .07em;
    }
    
    /* Caseless scripts gain nothing from the transform but still get the tracking — reset both. */
    .myplugin__divider span:lang(ka),
    .myplugin__divider span:lang(he),
    .myplugin__divider span:lang(ar),
    .myplugin__divider span:lang(ja),
    .myplugin__divider span:lang(ko),
    .myplugin__divider span:lang(zh),
    .myplugin__divider span:lang(th) {
      text-transform: none;
      letter-spacing: normal;
    }
    ```
    
    `:lang()` matches the document's `lang` attribute, which WordPress emits from the site locale via
    `language_attributes()` — so this needs no PHP.
    
    **Don't size containers to English string lengths.** Translations commonly run 20–40% longer
    (German, Georgian, Finnish) or much shorter (CJK). Fixed-width buttons, `white-space: nowrap` on
    labels, and single-line tab strips all fail on the first translated site. Prefer intrinsic sizing
    plus wrapping; if a value must not wrap, give it `min-width: 0` and `overflow-wrap: anywhere` so
    it shrinks and breaks rather than overflowing (see field-notes.md §4 on the flex `min-width`
    footgun).
    
    **Reminder:** if you ship any of this as a hardcoded value you have also broken Golden Rule #6 —
    alignment, tracking and transform should be Elementor controls, and then the user can fix it
    themselves in any language.
    
  • mariadb.md 15.6 KB
    # MariaDB / MySQL — the database layer for WordPress plugins
    
    > **When to read this file:** Creating or altering a **custom table**, writing **direct `$wpdb`
    > queries**, diagnosing a **slow query**, choosing **column types or indexes**, or hitting a
    > charset / collation / "Specified key was too long" error. Distilled from the official **MariaDB
    > Knowledge Base** (mariadb.com/kb) and the WordPress plugin handbook.
    >
    > **Scope:** WordPress runs on **both MariaDB and MySQL**, and a distributed plugin does not get to
    > choose which. Everything here is written for that reality — §6 lists the differences that
    > actually break portable SQL. For the `$wpdb->prepare()` security rules and the Plugin Check
    > sniffs that fire on direct queries, see **`php-standards.md`** and **`debugging.md`** §1; this
    > file is about *schema and performance*, not escaping.
    
    ---
    
    ## 1. Versions — what WordPress actually needs
    
    | | Recommended (wordpress.org/about/requirements) | Will still run |
    |---|---|---|
    | **MariaDB** | **10.11+** | 5.5.5+ (End of Life — a security liability) |
    | **MySQL** | **8.0+** | 5.5.5+ (End of Life) |
    
    - The recommended MariaDB floor was **raised from 10.6 to 10.11** — verify against the live page
      before quoting it; it moves.
    - **Never assume the server version.** Shared hosts run old builds. Check at runtime before using
      anything version-dependent:
      ```php
      global $wpdb;
      $version   = $wpdb->db_version();              // e.g. "10.11.6" — normalised by WP
      $is_maria  = false !== stripos( $wpdb->db_server_info(), 'mariadb' );
      ```
    - Declare nothing about the DB in your plugin header — there is no `Requires MySQL`. If you need a
      modern feature, **detect it and degrade**, don't document a requirement nobody reads.
    
    ---
    
    ## 2. Character sets & collations — and the 191-character rule
    
    **`utf8` is not UTF-8.** MariaDB has two implementations:
    
    | Charset | Bytes/char | Stores |
    |---|---|---|
    | `utf8mb3` (historically aliased as `utf8`) | 1–3 | Latin, European, Middle-Eastern scripts — **no supplementary characters** |
    | **`utf8mb4`** | 1–4 | **All of Unicode** — emoji, rare CJK, mathematical symbols |
    
    In MariaDB 10.6+, `utf8` remains an alias for **`utf8mb3`** by default (changeable via the
    `old_mode` system variable). So a column declared `utf8` **silently cannot store an emoji** — the
    classic "user pasted 🎉 and the row truncated or errored" bug.
    
    > ✅ **Never write a charset by hand. Use WordPress's:**
    > ```php
    > $charset_collate = $wpdb->get_charset_collate();   // e.g. "DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci"
    > $sql = "CREATE TABLE {$table_name} ( … ) {$charset_collate};";
    > ```
    > It reads `DB_CHARSET` / `DB_COLLATE` from `wp-config.php` and matches the rest of the install —
    > which also keeps your table **JOIN-able** with core tables. Joining columns of *different*
    > collations triggers `Illegal mix of collations` at runtime, not at `CREATE TABLE` time.
    
    ### ⚠️ The 191-character rule — why WordPress writes `varchar(191)`
    
    InnoDB caps an **index key** by page size (official limits):
    
    | InnoDB page size | Max index key length |
    |---|---|
    | 4 KB | 768 bytes |
    | 8 KB | 1,536 bytes |
    | **16 KB (the default)** | **3,072 bytes** |
    
    Older row formats (`COMPACT` / `REDUNDANT`) cap a single column prefix at **767 bytes**. At 4
    bytes per character under `utf8mb4`:
    
    ```
    767 ÷ 4 = 191.75  →  191 characters
    ```
    
    That is the entire reason WordPress core declares indexed string columns as **`varchar(191)`**
    rather than 255. Follow it:
    
    ```sql
    -- ❌ errors on older row formats: "Specified key was too long; max key length is 767 bytes"
    email varchar(255) NOT NULL,
    KEY email (email)
    
    -- ✅ safe everywhere
    email varchar(191) NOT NULL,
    KEY email (email)
    
    -- ✅ or index only a prefix when you need the full column length
    url varchar(2048) NOT NULL,
    KEY url (url(191))
    ```
    
    Modern `DYNAMIC` row format on 16 KB pages allows 3,072 bytes, so 255 chars *usually* works — but
    "usually" is not a basis for a distributed plugin. **Index at 191, or index a prefix.**
    
    ---
    
    ## 3. Custom tables — `dbDelta()` and its unforgiving syntax
    
    Only create a custom table when post types + meta genuinely don't fit (high-volume logs, analytics,
    queues). You lose the REST API, revisions, caching and every core query helper.
    
    ```php
    function myplugin_install_table(): void {
        global $wpdb;
    
        $table_name      = $wpdb->prefix . 'myplugin_events';   // ALWAYS use the prefix
        $charset_collate = $wpdb->get_charset_collate();
    
        $sql = "CREATE TABLE {$table_name} (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            user_id bigint(20) unsigned NOT NULL DEFAULT 0,
            event_type varchar(191) NOT NULL DEFAULT '',
            payload longtext NULL,
            created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY  (id),
            KEY user_id (user_id),
            KEY event_type_created (event_type, created_at)
        ) {$charset_collate};";
    
        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        dbDelta( $sql );
    
        update_option( 'myplugin_db_version', MYPLUGIN_DB_VERSION );
    }
    register_activation_hook( __FILE__, 'myplugin_install_table' );
    ```
    
    > 🔴 **`dbDelta()` parses your SQL with regexes — its formatting rules are absolute.** Break one and
    > it fails *silently* or re-runs `ALTER TABLE` on every load:
    > - **One field per line.** No exceptions.
    > - **Two spaces** after `PRIMARY KEY` — `PRIMARY KEY  (id)`. This is the single most common bug.
    > - Use **`KEY`**, never `INDEX`. Include **at least one** `KEY`.
    > - `KEY` → one space → key name → space → `(column)`.
    > - **No backticks or apostrophes** around field names.
    > - Field **types lowercase**, SQL keywords **uppercase**.
    > - Every type that accepts a length **must state it** — `bigint(20)`, not `bigint`.
    
    **Schema upgrades:** `register_activation_hook()` does **not** fire on plugin update (since WP 3.1).
    Version-gate the migration on a normal request instead:
    
    ```php
    add_action( 'plugins_loaded', function (): void {
        if ( get_option( 'myplugin_db_version' ) !== MYPLUGIN_DB_VERSION ) {
            myplugin_install_table();   // dbDelta() diffs and ALTERs — safe to re-run
        }
    } );
    ```
    
    `dbDelta()` **adds** columns and indexes; it never drops them. Removing a column is a deliberate,
    hand-written `ALTER TABLE` guarded by a version check.
    
    > ⚠️ **`CREATE TABLE` is DDL — Plugin Check flags it** (`DirectDatabaseQuery.SchemaChange`, plus
    > `.DirectQuery` and `.NoCaching`). Use the **block form** `phpcs:disable` / `phpcs:enable` with a
    > truthful justification — see `debugging.md` §1, which lists every sniff a `$wpdb` call can fire.
    
    ---
    
    ## 4. Indexes — the 80% that matters
    
    | Type | Purpose |
    |---|---|
    | **PRIMARY KEY** | Unique + `NOT NULL`, one per table. InnoDB **appends the primary key to every secondary index**, so keep it narrow — an `AUTO_INCREMENT` integer, never a long string. |
    | **UNIQUE** | Enforces uniqueness. Allows **multiple NULLs** — `NULL` never equals `NULL` in SQL, so a UNIQUE index will not stop repeated NULL rows. |
    | **KEY** (plain) | Speeds lookups, enforces nothing. |
    
    **Composite indexes follow the leftmost-prefix rule.** MariaDB can use the *leftmost part(s)* of a
    multi-column index, not an arbitrary middle slice. Given `KEY event_type_created (event_type, created_at)`:
    
    ```sql
    WHERE event_type = 'signup'                        -- ✅ uses the index
    WHERE event_type = 'signup' AND created_at > ?     -- ✅ uses both columns
    WHERE created_at > ?                               -- ❌ cannot use it — created_at is not leftmost
    ```
    Order composite columns by **equality filters first, ranges last**.
    
    **When an index will NOT be used:**
    - `LIKE '%term%'` — a **leading** wildcard defeats a BTREE index entirely (full scan). Leading-anchored
      `LIKE 'term%'` is fine.
    - A function or type-cast wrapped around the column (`WHERE DATE(created_at) = …`) — index the value
      you actually filter on, or store a pre-computed column.
    - Very small tables, where a scan is genuinely cheaper.
    - Low selectivity — an index on a two-value status column rarely earns its keep.
    
    **Limits (InnoDB, official):** 1,017 columns per table · **64 secondary indexes** · **32 columns per
    composite index** · row-size limit 65,535 bytes (BLOB/TEXT count only 9–12 bytes; their content lives
    outside the row).
    
    **Every index is a write tax.** Each `INSERT`/`UPDATE`/`DELETE` maintains every index on the table.
    Index what you filter, join and sort on — not every column.
    
    ---
    
    ## 5. Diagnosing a slow query — `EXPLAIN` and `ANALYZE`
    
    ```sql
    EXPLAIN SELECT * FROM wp_myplugin_events WHERE event_type = 'signup' ORDER BY created_at DESC;
    ```
    
    Read these four columns first:
    
    | Column | What it tells you |
    |---|---|
    | **`type`** | How rows are reached. Best→worst: `const`, `eq_ref`, `ref`, `range`, `index`, **`ALL`**. |
    | **`key`** | Which index was actually chosen. **`NULL` = none used.** |
    | **`rows`** | Estimated rows examined per lookup. Compare against the rows you expect back. |
    | **`Extra`** | The warnings live here. |
    
    🚩 **Red flags:**
    - **`type: ALL`** — full table scan, "bad if the table is large."
    - **`Using filesort`** — an extra sort pass; the `ORDER BY` isn't served by an index.
    - **`Using temporary`** — a temp table was built, typical of `GROUP BY` / `DISTINCT` / `ORDER BY`.
    - **`key: NULL`** on a large table — add the index, or fix the query so an existing one applies.
    
    **`ANALYZE`** (not `EXPLAIN`) *actually runs* the query and reports **`r_rows`** (rows really
    examined) and **`r_filtered`** beside the estimates. When the estimate and reality diverge wildly,
    the optimizer is working from stale statistics — that mismatch is the finding.
    `EXPLAIN FORMAT=JSON` gives the full plan for programmatic use.
    
    > **In WordPress, reach for Query Monitor first** (see `debugging.md` §2) — it shows every query with
    > its calling component and flags slow/duplicate ones, so you know *which* query to `EXPLAIN` before
    > you open a SQL client. Enable `SAVEQUERIES` to capture them.
    
    ---
    
    ## 6. MariaDB ≠ MySQL — what breaks portable SQL
    
    Your plugin will run on both. These are the officially-documented divergences that matter:
    
    - **🔴 JSON is the big one.** MariaDB does **not** support MySQL's packed/native binary JSON type —
      it stores JSON as ordinary **TEXT/LONGTEXT** and *compares JSON as strings, not by JSON value*.
      MySQL compares by value. Consequences:
      - The `->` and `->>` shorthand operators are MySQL-native and arrived in MariaDB only much later.
        **Don't use them in distributed code** — prefer `JSON_EXTRACT()` / `JSON_UNQUOTE()`, present in
        both, and even then test on both servers.
      - **Never make JSON equality or sorting the server's job.** Two documents that MySQL calls equal
        may differ as strings in MariaDB.
      - ✅ **The portable WordPress answer:** store JSON (or a serialized array) in `longtext`, filter on
        real indexed columns beside it, and decode in PHP. If you need to query *inside* a document, you
        need a proper column — that is the schema telling you something.
    - **`UNIX_TIMESTAMP()`** returns 6 decimal places on MariaDB and none on MySQL — a difference that
      silently breaks partitioning expressions and any code comparing the raw return value. Cast or round
      explicitly.
    - **`EXTRACT(HOUR FROM …)`** follows the SQL standard on MariaDB (0–23); MySQL may return larger values.
    - **Temporal storage formats** for `TIME` / `DATETIME` / `TIMESTAMP` differ at the byte level between
      MySQL 5.6 and MariaDB 10.0 — relevant to raw dumps and replication, not to normal SQL.
    - **Not in MariaDB:** `CREATE TABLESPACE` for InnoDB, the X Protocol, packed JSON. Encryption and GTID
      implementations differ and are **not** cross-compatible.
    
    > ✅ **Rule for distributed plugins: write plain, boring, standard SQL.** Stick to what both engines
    > have had for years. Every clever engine-specific feature is a support ticket from the half of your
    > users on the other one.
    
    ---
    
    ## 7. `sql_mode` — the setting that differs per server
    
    MariaDB's default since 10.2.4: `STRICT_TRANS_TABLES, ERROR_FOR_DIVISION_BY_ZERO,
    NO_AUTO_CREATE_USER, NO_ENGINE_SUBSTITUTION`.
    
    - **`STRICT_TRANS_TABLES`** — invalid or missing data **aborts and rolls back** the statement rather
      than silently coercing it. An `INSERT` omitting a `NOT NULL` column with no default *fails* here
      and *succeeds* on a lenient server. Always give columns sane `DEFAULT`s (as in §3).
    - **`ONLY_FULL_GROUP_BY`** — forbids selecting columns that are neither grouped nor aggregated.
      **MySQL enables it by default; MariaDB does not.** A `GROUP BY` query that works perfectly on your
      MariaDB dev box can error on a user's MySQL host. Select only grouped or aggregated columns.
    - **`NO_ZERO_DATE`** — rejects `'0000-00-00'`. Legacy WordPress data contains these; never write one.
    
    **The real lesson:** a permissive dev server hides bugs that a strict production server throws. Test
    against strict mode, and never rely on silent coercion.
    
    ---
    
    ## 8. Full-text search — and why usually not
    
    Full-text indexes work on **MyISAM, Aria, InnoDB and Mroonga**, on `CHAR` / `VARCHAR` / `TEXT`
    columns only, and **not on partitioned tables**.
    
    ```sql
    SELECT * FROM wp_myplugin_docs
     WHERE MATCH (title, body) AGAINST ('wordpress elementor' IN BOOLEAN MODE);
    ```
    
    - **Natural language mode** (default) ranks by relevance; **boolean mode** supports `+` required,
      `-` excluded, `*` wildcard, `"…"` phrase — but does not rank by relevance.
    - ⚠️ **Short words are silently ignored:** under 3 characters on InnoDB, under 4 on MyISAM
      (`innodb_ft_min_token_size` / `ft_min_word_length`). Users searching a 2-letter term get nothing
      back, with no error — a classic "search is broken" report.
    - **Stopwords** (common words) are filtered out except in boolean mode.
    
    > **For plugins, prefer `WP_Query`'s `s` parameter** — it respects post types, statuses and
    > capabilities, and stays compatible with search plugins the user already runs. Reach for a
    > `MATCH … AGAINST` index only on your **own** custom table, when `LIKE '%term%'` (which cannot use
    > an index — §4) has become the measured bottleneck.
    
    ---
    
    ## 9. Querying from PHP — the short version
    
    Full security rules live in **`php-standards.md`**; the schema-side essentials:
    
    ```php
    global $wpdb;
    
    // ✅ Values are BOUND. Identifiers (table/column names) are never bindable —
    //    build them from $wpdb->prefix and your own whitelist, never from user input.
    $rows = $wpdb->get_results(
        $wpdb->prepare(
            "SELECT id, event_type, created_at
               FROM {$wpdb->prefix}myplugin_events
              WHERE user_id = %d AND created_at > %s
              ORDER BY created_at DESC
              LIMIT %d",
            $user_id,
            $since,
            $limit
        )
    );
    ```
    
    - **`prepare()` needs at least one placeholder** — `prepare( $sql )` with none is the
      "missing argument 2" anti-pattern. An always-present `LIMIT %d` guarantees one.
    - **`%s` / `%d` / `%f` are not quoted by you** — `prepare()` adds the quotes. `'%s'` is a bug.
    - **`%i`** (WP 6.2+) safely interpolates an **identifier**; still validate it against a whitelist.
    - **Never `SELECT *`** on a hot path — name the columns so a **covering index** can serve the query
      without touching the row.
    - **Cache reads** (`wp_cache_get` / transients — see `php-standards.md`), and remember that under a
      persistent object cache transients are **not** in `wp_options`.
    - Use `$wpdb->prefix` for per-site tables and **`$wpdb->base_prefix`** for a network-wide table on
      multisite.
    
    ---
    
    > **Sources:** mariadb.com/kb — Unicode · InnoDB Limitations · Getting Started with Indexes ·
    > EXPLAIN/ANALYZE · SQL Mode · Full-Text Index Overview · MariaDB vs MySQL Compatibility ·
    > developer.wordpress.org/plugins/creating-tables-with-plugins/ · wordpress.org/about/requirements/
    
  • offcanvas-ui.md 8.8 KB
    # Off-Canvas Filter UI — Full Pattern
    
    > **Native Off-Canvas widget vs this pattern — pick the right tool:**
    > Elementor ships a free native **Off-Canvas** widget (since ~3.23). Prefer it when the panel is
    > **content designed visually in the editor** (menus, a cart drawer, promo content) and a
    > trigger/overlay/close out of the box is enough — no code required.
    > Use **this custom-code pattern** when you need: a panel rendered at `wp_body_open` (outside any
    > transformed Elementor ancestor — see the transform trap below), a **filter UI** wired to a Loop
    > Grid / custom query, an `inert` + focus-trap implementation you fully control, or behaviour the
    > native widget doesn't expose. The two are not mutually exclusive — you can style the native
    > widget and reserve this pattern for bespoke cases.
    > Native widget docs: elementor.com/help/off-canvas-widget/
    
    ```php
    // Placement: Elementor Custom Code → wp_body_open
    // CSS must hide the panel by default via class, NOT aria-hidden on the element itself.
    // aria-hidden="true" on a role="dialog" with aria-modal conflicts with ARIA spec
    // and causes inconsistent screen reader behaviour. Use CSS visibility instead.
    ?>
    <div id="myplugin-offcanvas"
         class="myplugin-offcanvas myplugin-offcanvas--hidden"
         role="dialog"
         aria-modal="true"
         aria-labelledby="myplugin-offcanvas-title"
         inert>
    
        <div class="myplugin-offcanvas__header">
            <h2 id="myplugin-offcanvas-title" class="myplugin-offcanvas__title">
                <?php esc_html_e( 'Filter Results', 'myplugin' ); ?>
            </h2>
            <button class="myplugin-offcanvas__close"
                    aria-label="<?php esc_attr_e( 'Close filters', 'myplugin' ); ?>">
                &#10005;
            </button>
        </div>
    
        <div class="myplugin-offcanvas__body">
            <!-- filter controls here -->
        </div>
    </div>
    
    <button class="myplugin-offcanvas__trigger"
            aria-controls="myplugin-offcanvas"
            aria-expanded="false">
        <?php esc_html_e( 'Open Filters', 'myplugin' ); ?>
    </button>
    
    <!--
        ✅ Backdrop overlay — click to close the panel.
        Placed AFTER the panel (not inside it) so it sits behind the panel in stacking context.
        Controlled by the same JS open/close functions via the 'myplugin-offcanvas--hidden' class.
    -->
    <!--
        ✅ Backdrop starts with --hidden class in HTML — matches the panel's initial hidden state.
        Without this, the dark overlay is VISIBLE before JS loads and adds the class at init time.
        JS removes the class on open() and re-adds it on close(). Same pattern as the inert attribute
        on the panel: accessibility/visibility state must be set in HTML, not only in JS.
    -->
    <div class="myplugin-offcanvas__backdrop myplugin-offcanvas__backdrop--hidden" aria-hidden="true"></div>
    ```
    
    ```css
    /* ⚠️ visibility:hidden alone does NOT block keyboard Tab into focusable children.
       The `inert` attribute (toggled by JS below) handles that — baseline supported
       in all major browsers since April 2023 (95%+ coverage). */
    
    /* ✅ transition MUST be on the BASE element — not only on the --hidden modifier.
       If transition is placed only on --hidden, the open animation (class removal)
       runs without a transition because the rule disappears with the class. */
    .myplugin-offcanvas {
      position: fixed;   /* viewport-relative overlay — removed from document flow */
      top: 0;
      left: 0;
      width: 320px;
    
      /* ✅ Use 100dvh (dynamic viewport height) NOT 100vh.
         On iOS Safari, 100vh is the layout viewport height which does NOT account for
         the browser chrome. 100dvh reflects the actual visible viewport height.
         Supported: Chrome 108+, Safari 15.4+, Firefox 116+. */
      height: 100vh;     /* fallback for browsers without dvh support */
      height: 100dvh;    /* ✅ correct — accounts for mobile browser chrome */
    
      z-index: 9999;
      overflow-y: auto;
    
      /* ⚠️ ELEMENTOR TRANSFORM TRAP: position:fixed is broken when a parent has a
         non-none CSS transform (Elementor applies transforms to sections/columns).
         The wp_body_open placement ensures this element is a direct child of <body>,
         which has no transform. Never move this markup inside an Elementor widget. */
    
      visibility: visible;
      pointer-events: auto;
      transform: translateX(0);
      transition: transform 0.3s ease, visibility 0.3s ease; /* ← must be here, not on --hidden */
    }
    .myplugin-offcanvas--hidden {
      visibility: hidden;
      pointer-events: none;
      transform: translateX(-100%);
      /* Do NOT repeat transition here — the base class handles both directions */
    }
    
    /* ── Backdrop overlay ───────────────────────────────────── */
    .myplugin-offcanvas__backdrop {
      position: fixed;
      inset: 0;                     /* covers full viewport */
      background: rgba( 0, 0, 0, 0.5 );
      z-index: 9998;                /* one below the panel (9999) */
      opacity: 1;
      transition: opacity 0.3s ease, visibility 0.3s ease;
      visibility: visible;
    }
    /* Hidden state — toggled by JS alongside the panel */
    .myplugin-offcanvas__backdrop.myplugin-offcanvas__backdrop--hidden {
      opacity: 0;
      visibility: hidden;
      pointer-events: none;
    }
    ```
    
    ```js
    // Off-canvas JS — CSS class toggle + inert + ARIA expanded + focus trap + scroll lock
    ( () => {
      'use strict';
    
      const panel   = document.getElementById( 'myplugin-offcanvas' );
      const trigger = document.querySelector( '.myplugin-offcanvas__trigger' );
      const close   = panel?.querySelector( '.myplugin-offcanvas__close' );
    
      if ( ! panel || ! trigger ) return;
    
      const HIDDEN_CLASS = 'myplugin-offcanvas--hidden';
      // ✅ a[href] not [href] — only <a> elements with an href are natively keyboard focusable
      const focusable = 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])';
    
      const backdrop = document.querySelector( '.myplugin-offcanvas__backdrop' );
    
      const open = () => {
        panel.classList.remove( HIDDEN_CLASS );
        panel.inert = false;                        // ✅ re-enable keyboard + AT access
        trigger.setAttribute( 'aria-expanded', 'true' );
        backdrop?.classList.remove( 'myplugin-offcanvas__backdrop--hidden' );
    
        // ✅ iOS Safari scroll lock — overflow:hidden on body does NOT prevent background
        // scroll on iOS Safari. Store scroll position, fix body in place, restore on close.
        const scrollY = window.scrollY;
        document.body.style.position   = 'fixed';
        document.body.style.top        = `-${ scrollY }px`;
        document.body.style.width      = '100%';
        document.body.dataset.scrollY  = scrollY;
    
        // ✅ Respect prefers-reduced-motion — when animations are disabled, don't wait 300ms
        const delay = window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches ? 0 : 300;
        setTimeout( () => panel.querySelector( focusable )?.focus(), delay );
      };
    
      const closePanel = () => {
        panel.classList.add( HIDDEN_CLASS );
        panel.inert = true;                         // ✅ block Tab focus into hidden panel
        trigger.setAttribute( 'aria-expanded', 'false' );
    
        const scrollY = parseInt( document.body.dataset.scrollY || '0', 10 );
        document.body.style.position  = '';
        document.body.style.top       = '';
        document.body.style.width     = '';
        // ✅ Restore scroll BEFORE deleting dataset.scrollY — prevents data loss on rapid
        // open/close cycles where a second panel.open() could read a deleted value.
        window.scrollTo( 0, scrollY );
        delete document.body.dataset.scrollY;
    
        trigger.focus();
        backdrop?.classList.add( 'myplugin-offcanvas__backdrop--hidden' );
      };
    
      // Initialize panel and backdrop as hidden on load.
      // panel already has inert + --hidden class set in HTML markup — this is a JS-side
      // safety net in case the widget is dynamically injected after page load (e.g. via AJAX).
      panel.inert = true;
      backdrop?.classList.add( 'myplugin-offcanvas__backdrop--hidden' ); // idempotent — class already in HTML
    
      // Focus trap — Tab cycles within open panel
      // ✅ querySelectorAll runs fresh on each keydown so dynamically injected controls
      // (AJAX-loaded filter options) are always included in the trap
      panel.addEventListener( 'keydown', ( e ) => {
        if ( e.key !== 'Tab' ) return;
        const els = [ ...panel.querySelectorAll( focusable ) ];
        if ( els.length === 0 ) return;
        const first = els[0];
        const last  = els[ els.length - 1 ];
        if ( e.shiftKey && document.activeElement === first ) {
          e.preventDefault(); last.focus();
        } else if ( ! e.shiftKey && document.activeElement === last ) {
          e.preventDefault(); first.focus();
        }
      } );
    
      panel.addEventListener( 'keydown', ( e ) => { if ( e.key === 'Escape' ) closePanel(); } );
      trigger.addEventListener( 'click', open );
      close?.addEventListener( 'click', closePanel );
      // ✅ Backdrop click closes the panel — matches native dialog behaviour
      backdrop?.addEventListener( 'click', closePanel );
    } )();
    ```
    
  • performance.md 6.9 KB
    # Performance & Accessibility Checklists
    
    ## Frontend Performance
    
    - [ ] Scripts use `strategy: defer` via WP 6.3+ API — never block parsing.
          **Exception: Elementor widget scripts must NOT use `defer`** — use `[ 'in_footer' => true ]`
          with `elementor-frontend` declared as a dependency instead (see elementor-patterns.md Step 2)
    - [ ] Critical above-fold CSS inlined; rest loaded asynchronously
    - [ ] Version strings on all enqueued assets (`MYPLUGIN_VERSION`) for cache busting
    - [ ] LCP image has `fetchpriority="high"` and is NOT lazy-loaded
    - [ ] All below-fold images have `loading="lazy"` + explicit `width`/`height` (prevents CLS).
          **WP 6.7+:** `auto-sizes` is prepended to the `sizes` attribute of lazy-loaded images by
          default — the browser uses the rendered layout width to pick the correct srcset source.
          Can be disabled via the `wp_img_tag_add_auto_sizes` filter (added in 6.7.1).
    - [ ] WebP format served; AVIF where browser support allows
    - [ ] `wp_add_inline_script()` used for PHP→JS data passing (not `wp_localize_script()`);
          `wp_json_encode()` return value checked before passing — a `false` return outputs broken JS
    - [ ] **WP 6.9+ IE conditional comments removed** — `wp_script_add_data( 'handle', 'conditional', 'IE' )`
          and `wp_style_add_data( 'handle', 'conditional', 'IE' )` now trigger a deprecation notice
          if `WP_DEBUG` is true, and **the asset is entirely ignored** — it will not appear on the
          page at all. Remove any IE-conditional asset registration from plugins and child themes.
          Note: bundled files that were only ever loaded conditionally for IE remain in core as
          **empty stub files** — do not depend on them for any actual styles or scripts.
          Source: make.wordpress.org/core/2025/11/19/legacy-internet-explorer-code-removed/
    - [ ] **WP 6.8+ Speculative Loading** uses `prefetch` with `conservative` eagerness by default
          (Speculation Rules API) — only for logged-out users and only when pretty permalinks are
          enabled. The `wp_speculation_rules_configuration` filter receives
          `array( 'mode' => 'auto', 'eagerness' => 'auto' )` by default (WordPress Core resolves
          `'auto'` to `prefetch` + `conservative`; do not test for `'conservative'` directly — check
          for `'auto'` or the resolved output instead).
          If your plugin uses **one-time nonces**, **session state writes**, or **cart/checkout
          side-effects** that trigger on page load, exclude those URLs via the
          `wp_speculation_rules_href_exclude_paths` filter.
          Add the `no-prefetch` CSS class to a link *or its container element* to exclude that link
          (and all descendant links) from Core's prefetch speculation rule — WP Core's selector is
          `.no-prefetch, .no-prefetch a`; use `no-prerender` for the prerender rule
          (selector: `.no-prerender, .no-prerender a`).
          Test coverage with DevTools → Application → Speculation Rules.
    
    ---
    
    ## Backend / Database Performance
    
    - [ ] `no_found_rows => true` on WP_Query when pagination is absent
    - [ ] `update_post_meta_cache => false`, `update_term_meta_cache => false` when metadata not needed
    - [ ] Expensive queries or remote calls wrapped in `get_transient` / `set_transient`
    - [ ] Transient keys are ≤ 172 characters — dynamic keys use `md5()` to prevent silent cache misses
    - [ ] No `SELECT *` — use `$wpdb->get_results` with explicit column names
    - [ ] No queries inside loops — pre-fetch with a single WP_Query, then loop results
    - [ ] **Slow query → run `EXPLAIN` before guessing.** `type: ALL` or `key: NULL` on a large table
          means no index is being used; `Using filesort` / `Using temporary` mean the sort or grouping
          isn't served by one. Composite indexes only apply **leftmost-first**, and `LIKE '%term%'`
          can never use a BTREE index. Full column/index reference: **`mariadb.md`** §4–§5.
    - [ ] Custom tables: index what you **filter, join and sort** on — and no more; every index is a
          write tax on `INSERT`/`UPDATE`/`DELETE` (`mariadb.md` §4)
    - [ ] **WP 7.0 Real-Time Collaboration (RTC) — scope your queries:** WP 7.0 (released May 20,
          2026) stores RTC collaboration data in a **dedicated core database table**, not in
          `wp_posts`/`wp_post_meta`. (An earlier `wp_sync_storage` post-meta design was rejected;
          building the table is what delayed 7.0 from April to May.) Because the data lives in its
          own table, general post queries are not polluted by it — but the durable best practice
          stands: give every `WP_Query`/`get_posts()` an explicit `'post_type' => 'your_cpt'` rather
          than relying on defaults, so no future internal post type can ever leak into your results.
          Do not hardcode exclusions against internal core type slugs.
          Source: make.wordpress.org/core/2026/04/22/wordpress-7-0-release-party-updated-schedule/
    - [ ] **WP 6.9+ WP_Query cache key change** — WordPress 6.9 changed how cache keys are generated
          for queries performed through `WP_Query` (and other query classes: `WP_Term_Query`,
          `WP_Comment_Query`, `WP_User_Query`, etc.). Cache keys now store alongside the
          last-changed timestamp and validate it on retrieval, instead of embedding it in the key.
          This is compatible with all existing persistent object cache drop-ins.
          However: if your plugin directly reads or writes to cache groups such as `post-queries`,
          `term-queries`, `comment-queries`, `user-queries`, or checks specific keys, review against
          the four new functions introduced in WP 6.9: `wp_cache_get_salted()`,
          `wp_cache_set_salted()`, `wp_cache_get_multiple_salted()`, `wp_cache_set_multiple_salted()`.
          **The order of items in the array of salts must be consistent** or cache hits will be missed.
          Source: make.wordpress.org/core/2025/11/17/consistent-cache-keys-for-query-groups-in-wordpress-6-9/
    
    ---
    
    ## Accessibility Checklist (WCAG 2.2 AA)
    
    - [ ] Interactive elements use `<button>` or `<a>` — never `<div onClick>` or `<span onClick>`
    - [ ] All `<img>` have descriptive `alt`; decorative images use `alt=""`
    - [ ] Color contrast ≥ 4.5:1 for body text; ≥ 3:1 for large text and UI components
    - [ ] `:focus-visible` styles present and visible on every interactive element
    - [ ] Off-canvas / modal panels use `role="dialog"`, `aria-modal="true"`, `aria-labelledby`,
          Escape key close, focus trap, **CSS class toggle for visibility**, and **`inert` attribute
          toggled by JS** to block keyboard Tab into hidden panels — never `aria-hidden` on a
          `role="dialog"` element (ARIA spec conflict); `visibility:hidden` alone does not block
          keyboard focus (see offcanvas-ui.md for the complete pattern)
    - [ ] Dynamic content changes announced via `aria-live="polite"` (non-critical) or `"assertive"` (errors)
    - [ ] Form labels explicitly associated via `for`/`id` pairing or `aria-labelledby`
    - [ ] Full keyboard navigation without mouse — Tab, Shift+Tab, Enter, Escape all functional
    
  • php-standards.md 9.8 KB
    # PHP Standards
    
    ## Sanitization, Escaping, and Nonces
    
    ```php
    // INPUT — always sanitize immediately on receipt
    // ✅ wp_unslash() BEFORE sanitize_text_field / sanitize_email / wp_kses_post / sanitize_url.
    // WordPress magic-quotes all $_POST/$_GET superglobals. Without wp_unslash() first,
    // a value like "O'Reilly" is sanitized as "O\'Reilly" and stored with corrupt backslashes.
    // Exception: absint() — integers cannot contain slashes, so wp_unslash() is not needed.
    // Exception: sanitize_key() — strips slashes implicitly (see WPCS wiki).
    $name    = sanitize_text_field( wp_unslash( $_POST['name'] ?? '' ) );
    $email   = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) );
    $id      = absint( $_GET['post_id'] ?? 0 );
    $content = wp_kses_post( wp_unslash( $_POST['content'] ?? '' ) );
    
    // ✅ sanitize_url() is the canonical function for URL input sanitization.
    // History: existed since WP 2.3.1, deprecated in WP 2.8.0 in favour of esc_url_raw(),
    // then UN-DEPRECATED and restored as canonical in WP 5.9.0 to honour the naming convention
    // "use sanitize_ to sanitize, esc_ to escape". esc_url_raw() is now an alias for sanitize_url().
    // Use sanitize_url() — it is correct, un-deprecated, and WPCS-clean on WP 5.9+.
    // Source: make.wordpress.org/plugins/2022/05/25/rejoice-to-sanitize_url/
    //         developer.wordpress.org/reference/functions/sanitize_url/ (restored since 5.9)
    //
    // ⚠️ WP 6.9 BEHAVIOUR CHANGE — esc_url(), esc_url_raw(), and sanitize_url():
    // In WP 6.8 and earlier, a protocol-less URL (e.g. "example.com/path") was prepended
    // with http:// before processing. Starting in WP 6.9, if 'https' is the first item in
    // the $protocols argument (which it IS by default), the functions prepend https:// instead.
    // Test any code that passes bare URLs (no scheme) through these functions after upgrading.
    // Source: make.wordpress.org/core/2025/11/19/url-escaping-functions-can-support-https-as-the-default-protocol-in-wordpress-6-9/
    $url     = sanitize_url( wp_unslash( $_POST['redirect'] ?? '' ) );
    
    // OUTPUT — always escape at the point of output
    echo esc_html( $name );
    echo esc_attr( $name );          // inside HTML attributes
    echo esc_url( $url );            // href, src
    echo wp_kses_post( $content );   // rich HTML content
    
    // NONCES — on every form and AJAX action
    // Render:
    wp_nonce_field( 'myplugin_save_action', 'myplugin_nonce' );
    
    // Verify:
    // ✅ sanitize_key() is the WPCS-canonical sanitizer for nonces — it strips slashes
    // automatically so wp_unslash() is not required (unlike sanitize_text_field).
    if ( ! isset( $_POST['myplugin_nonce'] )
        || ! wp_verify_nonce( sanitize_key( $_POST['myplugin_nonce'] ), 'myplugin_save_action' )
    ) {
        wp_die( esc_html__( 'Security check failed.', 'myplugin' ) );
    }
    ```
    
    ---
    
    ## WP_Error and External API Calls
    
    ```php
    // Always use WP_Error for communicable failures
    function myplugin_fetch_data( int $post_id ): array|WP_Error {
        if ( $post_id <= 0 ) {
            return new WP_Error( 'invalid_id', __( 'Invalid post ID.', 'myplugin' ) );
        }
    
        $response = wp_remote_get(
            'https://api.example.com/data/' . $post_id,
            [ 'timeout' => 10, 'sslverify' => true ]
        );
    
        if ( is_wp_error( $response ) ) {
            return $response;
        }
    
        $code = wp_remote_retrieve_response_code( $response );
        if ( 200 !== $code ) {
            return new WP_Error( 'api_error', sprintf(
                /* translators: %d: HTTP response code */
                __( 'API returned status %d.', 'myplugin' ),
                $code
            ), [ 'status' => $code ] );
        }
    
        $body    = wp_remote_retrieve_body( $response );
        $decoded = json_decode( $body, true );
        return is_array( $decoded ) ? $decoded : [];
    }
    
    // Caller always checks for errors
    $data = myplugin_fetch_data( $post_id );
    if ( is_wp_error( $data ) ) {
        // ✅ Guard error_log() with WP_DEBUG — raw error messages can leak sensitive
        // data (API keys, user data) into server logs on hosts where WP_DEBUG_LOG is enabled.
        if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
            error_log( 'myplugin: ' . $data->get_error_message() );
        }
    }
    ```
    
    ---
    
    ## Transient Caching
    
    ```php
    // ⚠️ TRANSIENT KEY LIMIT: 172 characters maximum (WordPress enforces this silently —
    // keys over the limit appear to save but are never retrieved).
    // For dynamic keys built from multiple segments, use md5():
    
    // ✅ Simple key — safe
    $cache_key = 'myplugin_data_' . $post_id;
    
    // ✅ Dynamic key — use md5() to guarantee safe length
    $cache_key = 'myplugin_' . md5( $post_id . '_' . $user_id . '_' . $locale );
    
    function myplugin_get_cached_data( int $post_id ): array {
        $cache_key = 'myplugin_data_' . $post_id;
        $cached    = get_transient( $cache_key );
    
        if ( false !== $cached ) {
            return $cached;
        }
    
        $data = myplugin_fetch_data( $post_id );
    
        if ( ! is_wp_error( $data ) ) {
            set_transient( $cache_key, $data, HOUR_IN_SECONDS );
        }
    
        return is_wp_error( $data ) ? [] : $data;
    }
    ```
    
    > ⚠️ **Under a persistent object cache (Redis/Memcached), transients are NOT in `wp_options`.**
    > A direct `DELETE … LIKE '_transient_%'` on the options table silently no-ops — "revoke all" /
    > "clear all" *appears* to work but nothing is invalidated (a real production bug). Never
    > bulk-delete transients with SQL. For group invalidation, keep an options-backed **generation
    > counter** (the counter is part of every cache key; bump it to invalidate everything at once) or
    > a registry of known keys removed via `delete_transient()`.
    
    ---
    
    ## General PHP Rules
    
    ```php
    // ✅ Bail early — avoid deeply nested conditionals
    function myplugin_process(): void {
        if ( ! is_user_logged_in() ) return;
        if ( ! current_user_can( 'edit_posts' ) ) return;
        // ... proceed
    }
    
    // ✅ Prefix ALL global symbols
    function myplugin_my_function(): void {}
    class MyPlugin_Utility {}
    define( 'MYPLUGIN_DEBUG', false );
    do_action( 'myplugin/after_save', $post_id );
    
    // ✅ OOP for anything beyond ~20 lines
    // ✅ Use WP_Query / get_posts() — avoid raw $wpdb unless unavoidable
    // ✅ Never suppress errors with @ — fix the root cause
    
    // ✅ PHP 8.2+ readonly classes — ideal for immutable plugin config/value objects
    readonly class MyPlugin_Config {
        public function __construct(
            public string $api_key,
            public int    $cache_ttl,
            public string $endpoint,
        ) {}
    }
    
    // ✅ PHP 8.3+ typed class constants — prefer over untyped define() in OOP contexts
    class MyPlugin_Constants {
        const string VERSION   = '1.0.0';
        const int    CACHE_TTL = 3600;
    }
    ```
    
    > ⚠️ **`declare(strict_types=1)` placement rule:** Must be the **very first statement** in
    > its file, immediately after `<?php` — before any output, namespace, use, require, or other
    > statements. Every PHP file that uses it must open like this:
    > ```php
    > <?php
    > declare( strict_types=1 );
    > // ... rest of file
    > ```
    
    ---
    
    ## WP 6.8+ Password Hashing (bcrypt / BLAKE2b)
    
    > ⚠️ **Breaking change in WP 6.8 (April 2025):** WordPress replaced the old phpass
    > MD5-based scheme with **bcrypt** for user passwords and **BLAKE2b** for application
    > passwords and security keys.
    >
    > **Hash prefix reference (WP 6.8+):**
    > - `$wp$2y$` — bcrypt (user passwords via `wp_hash_password()`). The `$wp` prefix
    >   distinguishes WordPress's pre-hashed bcrypt from vanilla bcrypt used by third-party plugins.
    > - `$generic$` — BLAKE2b via Sodium (application passwords, password reset keys, personal
    >   data request keys, recovery mode key), used via `wp_fast_hash()`.
    > - `$P$` — Legacy phpass hash (still valid; rehashed opportunistically on next login).
    > - **Post passwords** still use phpass in WP 6.8.
    >
    > Source: make.wordpress.org/core/2025/02/17/wordpress-6-8-will-use-bcrypt-for-password-hashing/
    
    ```php
    // ✅ ALWAYS use wp_check_password() — never raw string comparison against user_pass.
    if ( wp_check_password( $plain_password, $stored_hash, $user->ID ) ) {
    
        // ✅ WP 6.8+: check whether the stored hash needs upgrading to bcrypt.
        if ( wp_password_needs_rehash( $stored_hash ) ) {
            // ✅ wp_set_password() takes the PLAINTEXT password and hashes internally.
            // Do NOT pre-hash with wp_hash_password() — double-hashing permanently locks users out.
            //
            // ✅ wp_set_password() does NOT send any password-change email and does NOT fire
            // 'after_password_reset'. It only fires the 'wp_set_password' action (WP 6.2+).
            // 'after_password_reset' is fired by WordPress's higher-level reset_password()
            // function, which calls wp_set_password() internally. Calling wp_set_password()
            // directly (as in this rehash flow) does not trigger that hook at all.
            // Source: developer.wordpress.org/reference/functions/wp_set_password/
            //         developer.wordpress.org/reference/functions/reset_password/
            //
            // ⚠️ SESSION INVALIDATION: wp_set_password() invalidates ALL existing auth cookies
            // for this user immediately. Call wp_set_auth_cookie() afterward to re-issue a valid
            // cookie for the current session if you want the user to stay logged in.
            wp_set_password( $plain_password, $user->ID );
        }
    }
    
    // ✅ To hash a password: always wp_hash_password() — never password_hash() directly.
    $hash = wp_hash_password( $plain_password );
    
    // ❌ NEVER inspect hash prefixes directly — they changed in WP 6.8 and may change again.
    
    // ✅ wp_fast_hash() / wp_verify_fast_hash() — BLAKE2b via Sodium.
    // ⚠️ IMPORTANT: HIGH-ENTROPY RANDOM INPUT ONLY (>128 bits).
    // MUST NOT be used for user passwords (use wp_hash_password()).
    // MUST NOT be used for low-entropy input such as user IDs, emails, short tokens
    // (use wp_hash() for those). Intended only for randomly-generated high-entropy secrets
    // such as application passwords and security keys.
    $hash  = wp_fast_hash( $random_secret );
    $valid = wp_verify_fast_hash( $input, $hash ); // returns bool
    ```
    
  • rest-api.md 5.8 KB
    # REST API Endpoints
    
    ```php
    // Always in a plugin — never in functions.php
    add_action( 'rest_api_init', function() {
        register_rest_route( 'myplugin/v1', '/items/(?P<id>\d+)', [
            'methods'             => \WP_REST_Server::READABLE,
            'callback'            => 'myplugin_rest_get_item',
            'permission_callback' => 'myplugin_rest_permissions',
            'args'                => [
                'id' => [
                    // ✅ Use WP's built-in schema validation — NOT a custom is_numeric() closure.
                    // is_numeric() accepts floats ('1.5'), negatives ('-1'), and scientific notation
                    // ('1e5'), none of which are valid post IDs. The 'type' + 'minimum' +
                    // 'rest_validate_request_arg' pattern is the canonical WP REST API approach.
                    'type'              => 'integer',
                    'minimum'           => 1,
                    'sanitize_callback' => 'absint',
                    'validate_callback' => 'rest_validate_request_arg',
                    'required'          => true,
                    'description'       => 'The ID of the item to retrieve.',
                ],
            ],
            // ✅ Always include a schema callback — required for REST API schema discovery,
            // client code generation, and WP-CLI route inspection.
            'schema' => 'myplugin_get_item_schema',
        ] );
    } );
    
    /**
     * JSON Schema for the /items/{id} endpoint.
     */
    function myplugin_get_item_schema(): array {
        return [
            // ✅ WordPress REST API uses JSON Schema draft-04 — NOT draft-07.
            // WP core validates against draft-04 via its own schema validator. Using
            // draft-07 keywords (e.g. boolean 'exclusiveMinimum', 'if/then/else', '$ref' resolution)
            // will be silently ignored or cause validation errors on WP 6.x+.
            // Source: developer.wordpress.org/rest-api/extending-the-rest-api/schema/
            '$schema'    => 'http://json-schema.org/draft-04/schema#',
            'title'      => 'myplugin_item',
            'type'       => 'object',
            'properties' => [
                'id'      => [ 'type' => 'integer', 'description' => 'Unique post ID.' ],
                'title'   => [ 'type' => 'string',  'description' => 'Post title.' ],
                'content' => [ 'type' => 'string',  'description' => 'Post content (filtered HTML).' ],
            ],
        ];
    }
    
    function myplugin_rest_permissions( WP_REST_Request $request ): bool|WP_Error {
        // Return WP_Error (not false) on denial — gives client a proper JSON error body.
        // Returning false sends a generic 403 with no context.
        if ( ! current_user_can( 'read' ) ) {
            return new WP_Error(
                'rest_forbidden',
                __( 'You do not have permission to access this resource.', 'myplugin' ),
                [ 'status' => 403 ]
            );
        }
        return true;
    }
    
    // ─── REST API Nonce — Authenticating JS Fetch Calls ─────────────────────────
    //
    // WordPress REST API uses a SEPARATE nonce from AJAX (wp_ajax_*).
    // The REST nonce is generated with: wp_create_nonce( 'wp_rest' )
    // It must be sent as the X-WP-Nonce request header (not a POST body param).
    //
    // ✅ CORRECT — pass nonce to JS via wp_add_inline_script():
    // $data = wp_json_encode( [ 'nonce' => wp_create_nonce( 'wp_rest' ), 'restUrl' => rest_url() ] );
    // wp_add_inline_script( 'myplugin-js', 'const mypluginRest = ' . $data . ';', 'before' );
    //
    // ✅ CORRECT — JS fetch with nonce header:
    // const res = await fetch( mypluginRest.restUrl + 'myplugin/v1/items/1', {
    //   headers: { 'X-WP-Nonce': mypluginRest.nonce, 'Content-Type': 'application/json' },
    // } );
    //
    // ⚠️ REST nonces expire after 24 hours. If your SPA/page stays open overnight, handle
    // 403 (rest_cookie_invalid_nonce) responses by fetching a fresh nonce and retrying:
    //
    // ✅ CORRECT nonce refresh — WordPress core registers wp_ajax_rest_nonce() on the
    // 'wp_ajax_rest_nonce' action (note: underscore, not hyphen). Calling this endpoint
    // with logged-in credentials returns a fresh wp_rest nonce as plain text:
    // Source: developer.wordpress.org/reference/functions/wp_ajax_rest_nonce/
    //
    // const refreshed  = await fetch( ajaxUrl + '?action=rest_nonce', { credentials: 'include' } );
    // mypluginRest.nonce = ( await refreshed.text() ).trim();
    // // then retry the original request with the new nonce header
    //
    // ⚠️ The action is 'rest_nonce' (underscore) not 'rest-nonce' (hyphen).
    // ⚠️ User must be logged in — this endpoint returns a nonce for cookie-based auth only.
    //
    // ⚠️ Public endpoints (no authentication required): use '__return_true' as permission_callback.
    // Never use '__return_false' — that permanently blocks all access including admins.
    // register_rest_route( 'myplugin/v1', '/public-data', [
    //     'methods'             => WP_REST_Server::READABLE,
    //     'callback'            => 'myplugin_public_callback',
    //     'permission_callback' => '__return_true',
    // ] );
    
    function myplugin_rest_get_item( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $id   = $request->get_param( 'id' );
        $post = get_post( $id );
    
        if ( ! $post || 'publish' !== $post->post_status ) {
            return new WP_Error( 'not_found', __( 'Item not found.', 'myplugin' ), [ 'status' => 404 ] );
        }
    
        // ✅ Set up global post context before apply_filters('the_content').
        // Without this, shortcodes, Gutenberg blocks, embeds, and any plugin that
        // reads the global $post will fail silently or produce wrong output.
        // wp_reset_postdata() restores the previous global $post state after rendering.
        $GLOBALS['post'] = $post;
        setup_postdata( $post );
    
        $response = rest_ensure_response( [
            'id'      => $post->ID,
            'title'   => get_the_title( $post ),
            'content' => apply_filters( 'the_content', $post->post_content ),
        ] );
    
        wp_reset_postdata();
    
        return $response;
    }
    ```
    
  • scaffolding.md 11.3 KB
    # Scaffolding — Plugin, Child Theme, CPT, AJAX
    
    ## Code Placement Rules
    
    **Always declare placement at the top of every code response.**
    
    | Destination | When to use |
    |---|---|
    | Child theme `functions.php` | Lightweight theme-scoped hooks, no reusability needed |
    | Elementor → Site Settings → Custom Code | JS/CSS snippets injected at `wp_head`, `wp_footer`, `wp_body_open` |
    | Dedicated plugin `includes/` class | Reusable logic, CPTs, REST endpoints, widget registration |
    | `wp-content/mu-plugins/` | Must-load logic, network-wide on multisite, security-critical code |
    | Elementor Widget PHP file | Custom `\Elementor\Widget_Base` extension, registered via hook |
    | `wp-content/themes/mytheme-child/woocommerce/` | WooCommerce template overrides — **last resort only** |
    
    ---
    
    ## Plugin Scaffolding
    
    Use when logic is reusable, complex, or must survive theme changes.
    
    ### File structure
    ```
    /wp-content/plugins/myplugin/
    ├── myplugin.php                        ← Main bootstrap file
    ├── includes/
    │   ├── class-myplugin.php              ← Singleton init class
    │   ├── class-myplugin-hooks.php        ← All add_action / add_filter
    │   ├── class-myplugin-assets.php       ← Enqueue logic
    │   ├── class-myplugin-rest.php         ← REST API endpoints (if needed)
    │   └── class-myplugin-widget.php       ← Elementor widget (if needed)
    ├── assets/
    │   ├── css/myplugin.css
    │   └── js/myplugin.js
    └── README.md
    ```
    
    ### Main plugin file (always use this header):
    ```php
    <?php
    /**
     * Plugin Name:  My Plugin
     * Description:  Short description.
     * Version:      1.0.0
     * Requires at least: 6.9
     * Requires PHP: 8.3
     * Requires Plugins: elementor
     * Author:       Your Name
     * License:      GPL-2.0-or-later
     * Text Domain:  myplugin
     */
    // ↑ "Requires Plugins" is the WP 6.5+ native plugin dependency declaration.
    // WordPress.org plugin slugs only — do NOT add inline comments on that line;
    // get_file_data() reads everything after the colon as the value, including comments.
    // NOTE: WordPress 7.0 (released May 2026) raised the minimum PHP to 7.4. Set "Requires PHP"
    // to at least 7.4 once you target WP 7.0+ sites exclusively (8.3 remains the recommended baseline).
    
    defined( 'ABSPATH' ) || exit;
    
    define( 'MYPLUGIN_VERSION', '1.0.0' );
    define( 'MYPLUGIN_PATH',    plugin_dir_path( __FILE__ ) );
    define( 'MYPLUGIN_URL',     plugin_dir_url( __FILE__ ) );
    
    // Bail early if Elementor is not active (when Elementor features are used)
    add_action( 'plugins_loaded', function() {
        if ( ! did_action( 'elementor/loaded' ) ) {
            add_action( 'admin_notices', function() {
                echo '<div class="notice notice-warning"><p>'
                    . esc_html__( 'My Plugin requires Elementor to be active.', 'myplugin' )
                    . '</p></div>';
            } );
            return;
        }
        require MYPLUGIN_PATH . 'includes/class-myplugin.php';
        MyPlugin::instance()->init();
    } );
    
    // ✅ WooCommerce HPOS compatibility declaration.
    // HPOS is enabled by default for new WooCommerce stores since v8.2 (October 2023).
    // Without this declaration, WooCommerce shows a blocking admin warning.
    // Remove this block if your plugin does not interact with WooCommerce at all.
    add_action( 'before_woocommerce_init', function() {
        if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
            \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility(
                'custom_order_tables', // HPOS feature identifier — do not change this string
                __FILE__,
                true  // true = compatible; false = not compatible
            );
        }
    } );
    ```
    
    ### Singleton init class pattern:
    ```php
    <?php
    // includes/class-myplugin.php
    defined( 'ABSPATH' ) || exit;
    
    class MyPlugin {
        private static ?self $instance = null;
    
        public static function instance(): self {
            if ( null === self::$instance ) {
                self::$instance = new self();
            }
            return self::$instance;
        }
    
        public function init(): void {
            require MYPLUGIN_PATH . 'includes/class-myplugin-hooks.php';
            require MYPLUGIN_PATH . 'includes/class-myplugin-assets.php';
            ( new MyPlugin_Hooks() )->register();
            ( new MyPlugin_Assets() )->register();
        }
    }
    ```
    
    ---
    
    ## Child Theme Scaffolding
    
    Use when customizing an existing theme without a dedicated plugin.
    
    ### File structure
    ```
    /wp-content/themes/mytheme-child/
    ├── style.css          ← Required header; do NOT import parent here
    ├── functions.php      ← Enqueue parent + child styles; all hooks go here
    └── screenshot.png     ← 1200×900px recommended
    ```
    
    ### style.css header:
    ```css
    /*
     * Theme Name:   My Theme Child
     * Template:     parent-theme-folder-name
     * Version:      1.0.0
     * Text Domain:  mytheme-child
     */
    ```
    
    ### functions.php — correct enqueue pattern:
    ```php
    <?php
    defined( 'ABSPATH' ) || exit;
    
    /**
     * Enqueue parent and child theme stylesheets.
     * Never use @import in style.css — always use wp_enqueue_style().
     */
    add_action( 'wp_enqueue_scripts', 'mytheme_child_enqueue_styles' );
    function mytheme_child_enqueue_styles(): void {
        $parent_style = 'parent-theme-style'; // match parent theme's registered handle
    
        wp_enqueue_style(
            $parent_style,
            get_template_directory_uri() . '/style.css',
            [],
            wp_get_theme( get_template() )->get( 'Version' )
        );
    
        wp_enqueue_style(
            'mytheme-child-style',
            get_stylesheet_uri(),
            [ $parent_style ],
            wp_get_theme()->get( 'Version' )
        );
    }
    ```
    
    ---
    
    ## Custom Post Type Registration
    
    ```php
    // Placement: dedicated plugin — never in functions.php
    // Always register on 'init', never on 'after_setup_theme' or earlier
    
    add_action( 'init', 'myplugin_register_post_types' );
    function myplugin_register_post_types(): void {
    
        register_post_type( 'myplugin_item', [
            'labels' => [
                'name'               => _x( 'Items', 'post type general name', 'myplugin' ),
                'singular_name'      => _x( 'Item', 'post type singular name', 'myplugin' ),
                'add_new_item'       => __( 'Add New Item', 'myplugin' ),
                'edit_item'          => __( 'Edit Item', 'myplugin' ),
                'view_item'          => __( 'View Item', 'myplugin' ),
                'search_items'       => __( 'Search Items', 'myplugin' ),
                'not_found'          => __( 'No items found.', 'myplugin' ),
                'not_found_in_trash' => __( 'No items found in Trash.', 'myplugin' ),
            ],
            'public'             => true,
            'has_archive'        => true,
            'show_in_rest'       => true,   // required for Gutenberg + Elementor Loop Grid
            'supports'           => [ 'title', 'editor', 'thumbnail', 'custom-fields' ],
            'menu_icon'          => 'dashicons-portfolio',
            'rewrite'            => [ 'slug' => 'items', 'with_front' => false ],
        ] );
    
        // Register associated taxonomy
        register_taxonomy( 'myplugin_category', 'myplugin_item', [
            'labels'            => [
                'name'          => _x( 'Item Categories', 'taxonomy general name', 'myplugin' ),
                'singular_name' => _x( 'Item Category', 'taxonomy singular name', 'myplugin' ),
            ],
            'hierarchical'  => true,
            'public'        => true,
            'show_in_rest'  => true,        // required for Elementor Loop Grid filtering
            'rewrite'       => [ 'slug' => 'item-category' ],
        ] );
    }
    
    // ⚠️ FLUSH REWRITE RULES: only on activation — NEVER on 'init' (too expensive).
    // register_activation_hook( __FILE__, function() {
    //     myplugin_register_post_types();   // register CPTs so rules exist before flush
    //     flush_rewrite_rules();
    // } );
    // Alternatively: Dashboard → Settings → Permalinks → Save Changes (manual one-time flush).
    ```
    
    ---
    
    ## AJAX Handler Pattern
    
    Complete PHP + JS pattern for secure WordPress AJAX:
    
    ```php
    // Placement: includes/class-myplugin-hooks.php
    
    // Register handlers — add wp_ajax_nopriv_ only if action is public-facing
    add_action( 'wp_ajax_myplugin_action',        'myplugin_ajax_handler' );
    add_action( 'wp_ajax_nopriv_myplugin_action', 'myplugin_ajax_handler' );
    
    /**
     * All wp_send_json_* functions call wp_die() internally.
     * Use check_ajax_referer() (not wp_verify_nonce) in AJAX handlers —
     * it is the idiomatic WP pattern, fires the check_ajax_referer action
     * hook for security logging, and correctly dies on failure.
     */
    function myplugin_ajax_handler(): void {
        // 1. Verify nonce — dies automatically with -1 output and 403 header on failure
        check_ajax_referer( 'myplugin_ajax', 'nonce' );
    
        // 2. Check capability — nonces confirm request origin, NOT permission
        if ( ! current_user_can( 'read' ) ) {
            wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'myplugin' ) ], 403 );
            return;
        }
    
        // 3. Sanitize and validate input
        $item_id = absint( $_POST['item_id'] ?? 0 );
        if ( $item_id <= 0 ) {
            wp_send_json_error( [ 'message' => __( 'Invalid item ID.', 'myplugin' ) ], 400 );
            return;
        }
    
        // 4. Process and respond — wp_send_json_success calls wp_die() internally
        $data = myplugin_get_cached_data( $item_id );
        wp_send_json_success( $data );
    }
    ```
    
    ```js
    // JS side — pairs with the wp_add_inline_script data (see js-css-standards.md)
    const fetchItem = async ( itemId ) => {
      const body = new FormData();
      body.append( 'action', 'myplugin_action' );
      body.append( 'nonce',   myPluginData.nonce );
      body.append( 'item_id', itemId );
    
      try {
        const res  = await fetch( myPluginData.ajaxUrl, { method: 'POST', body } );
        const json = await res.json();
    
        if ( ! json.success ) {
          console.error( 'myplugin AJAX error:', json.data?.message );
          return null;
        }
    
        return json.data;
      } catch ( err ) {
        console.error( 'myplugin fetch failed:', err );
        return null;
      }
    };
    ```
    
    ---
    
    ## Elementor Widget Registration Reminder
    
    When registering an Elementor widget from a plugin (via `elementor/widgets/register`),
    **every widget class must implement these two methods** — they are required for V4
    compatibility and output caching. Forgetting them in any widget (including those scaffolded
    from this file) will cause rendering issues and PHP warnings in future Elementor versions.
    
    ```php
    // ✅ REQUIRED on EVERY new widget — removes the redundant inner wrapper div.
    // Elementor 3.26+. Without this, the deprecated .elementor-widget-container wrapper
    // is still rendered, breaking Optimized Markup mode and V4 Atomic elements.
    // Return true ONLY if your render() output physically requires that wrapper.
    // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ REQUIRED on EVERY widget — controls Elementor output caching.
    // Return false  → Elementor may cache the rendered HTML (safe for static/non-user-specific output).
    // Return true   → Elementor skips caching (required if output varies per user, session, or time).
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return false; // change to true if output is user/session/time-specific
    }
    ```
    
    > **See `elementor-patterns.md` for the complete widget boilerplate**, and the relevant
    > `widget-*.md` file for control patterns matching Elementor's native widgets.
    
  • SKILL.md 37.7 KB
    ---
    name: wordpress-elementor-dev
    description: >
      Expert WordPress and Elementor Pro development skill. Use this whenever the user
      asks about WordPress theme development, Elementor Pro customization, plugin architecture,
      custom post types, hooks/filters, Loop Grids, Dynamic Tags, Theme Builder templates,
      off-canvas UIs, custom CSS/JS injection, performance optimization, or any PHP/JS/CSS
      code targeting a WordPress or Elementor environment. Trigger even for general questions
      like "how do I add a custom field in Elementor", "what hook should I use for X in
      WordPress", "how do I override a WooCommerce template", or "how do I register a
      custom widget" — do not wait for the user to explicitly say "use the skill."
      Also trigger for ACF integration, REST API endpoints, child theme setup, WooCommerce
      customization, WPML/multilingual setups, and any debugging of WordPress/Elementor issues.
    ---
    
    # WordPress & Elementor Pro — Skill Router
    
    A complete procedural guide for producing production-grade WordPress and Elementor Pro
    code. This file is the **router** — read it first, then load the relevant sub-file(s)
    for the task at hand.
    
    ---
    
    ## Sub-file Map — Read the Right File for Each Task
    
    | Task type | Read this file |
    |---|---|
    | Plugin scaffold, child theme, code placement, CPT, AJAX handler | **scaffolding.md** |
    | PHP standards, sanitization, escaping, nonces, WP_Error, transients, password hashing | **php-standards.md** |
    | JavaScript standards, enqueue API, defer/async, wp_add_inline_script | **js-css-standards.md** |
    | CSS standards, BEM, design tokens, Elementor CSS selectors | **js-css-standards.md** |
    | Elementor custom widget, Dynamic Tags, Loop Grid, Form actions, Theme Builder conditions | **elementor-patterns.md** |
    | Elementor extension points — custom **form fields**, **theme locations**, injecting controls into native widgets, **adding a TAB to the editor's Elements panel**, Finder/context-menu, hooks reference, deprecations | **elementor-extending.md** |
    | WooCommerce HPOS, order API, template overrides, Loop Grid for products | **woocommerce.md** |
    | REST API endpoints, schema, permission callbacks | **rest-api.md** |
    | Off-canvas UI, off-canvas accessibility, focus trap | **offcanvas-ui.md** |
    | Performance checklists (frontend + backend), speculative loading, IE conditional comments | **performance.md** |
    | Accessibility checklist, WCAG 2.2 AA, ARIA patterns | **performance.md** |
    | **Hard-won production gotchas** — widget lifecycle fatals, `content_template()` escaping, CSS-in-Elementor footguns, transactional email, wp.org review/packaging, embedding apps, AJAX/loop re-rendering, dynamic CSS in loops, Swiper on optimised sites, holding a login without counting it as failed | **field-notes.md** |
    | **wordpress.org submission** — the 18 Directory Guidelines, Plugin Check 2.1.0 categories/usage, review process, required headers/readme | **wp-org-guidelines.md** |
    | **Subversion (SVN)** — deploying/tagging a release on the wordpress.org plugin/theme SVN (trunk/tags/assets), the daily work cycle, branching/merging, properties, repo admin | **svn/svn.md** (self-contained sub-bundle; `svn/references/` goes deeper) |
    | **Debugging & static analysis** — PHPCS+WPCS, PHPStan, Plugin Check, `WP_DEBUG`/Query Monitor, Elementor Safe Mode/cache, symptom→cause table | **debugging.md** |
    | **Common WordPress APIs** — admin settings page (Settings + Options API), `register_meta`, roles/capabilities, WP-Cron, internationalization (i18n) | **wordpress-apis.md** |
    | **Database layer (MariaDB/MySQL)** — custom tables + `dbDelta()`, charsets & the `varchar(191)` index rule, indexes, `EXPLAIN`, MariaDB↔MySQL portability, `sql_mode`, full-text | **mariadb.md** |
    
    ### Widget Boilerplates — Load when building a widget of that type
    
    | Widget type | Read this file |
    |---|---|
    | Button / CTA with icon and link | **widget-button.md** |
    | Container / layout wrapper / section / card shell | **widget-container.md** |
    | Image with caption, link, lightbox | **widget-image.md** |
    | Heading / title / HTML tag selector | **widget-heading.md** |
    | Rich text / WYSIWYG body content | **widget-text-editor.md** |
    | Video embed (YouTube, Vimeo, self-hosted) | **widget-video.md** |
    | Widget that renders a saved Elementor template by ID | **widget-elementor-template.md** (SELECT2, `get_builder_content_for_display()`, Dynamic Tags context, CSS timing) |
    | Widget whose markup lives in a separate PHP template file | **widget-php-template.md** (Strategies A/B/C, `load_template()`, `ob_start`, path-traversal safety) |
    | Divider / horizontal rule with optional text or icon | **widget-divider.md** |
    | Spacer / vertical gap | **widget-spacer.md** |
    | Single standalone icon with optional link | **widget-icon.md** |
    | Icon + title + description box | **widget-icon-box.md** |
    | Image + title + description box | **widget-image-box.md** |
    | Image grid / gallery | **widget-image-gallery.md** |
    | Image slider / carousel | **widget-image-carousel.md** |
    | Bullet list with icons per item | **widget-icon-list.md** |
    | Animated number counter | **widget-counter.md** |
    | Percentage progress bar | **widget-progress.md** |
    | Customer quote / testimonial | **widget-testimonial.md** |
    | Tabbed content panels | **widget-tabs.md** |
    | Accordion — one panel open at a time | **widget-accordion.md** |
    | Toggle — multiple panels open simultaneously | **widget-toggle.md** |
    | Social media icon links row | **widget-social-icons.md** |
    | Colored alert / notice box | **widget-alert.md** |
    | Audio player (SoundCloud / self-hosted) | **widget-audio.md** |
    | WordPress shortcode output | **widget-shortcode.md** |
    | Raw custom HTML / JS / CSS embed | **widget-html.md** |
    | Named anchor for in-page navigation | **widget-menu-anchor.md** |
    | WordPress registered sidebar output | **widget-sidebar.md** |
    | WordPress <!--more--> read more tag | **widget-read-more.md** |
    | Google Maps embed | **widget-google-maps.md** |
    | Decorative star rating display | **widget-star-rating.md** |
    | Schema-ready structured rating | **widget-rating.md** |
    | Text following a curved / custom SVG path | **widget-text-path.md** |
    | Nested Tabs / Nested Accordion (each panel is a Container holding any widgets) | **widget-nested.md** |
    
    **Always read the relevant sub-file before writing code.** For tasks that span multiple
    areas (e.g. a WooCommerce widget with custom REST endpoint), read all relevant sub-files.
    For a widget task, read BOTH the widget boilerplate file AND **elementor-patterns.md**.
    For **any custom widget, plugin, or wp.org-bound work, also skim `field-notes.md`** — it
    catches the lint-passing, review-failing, site-down mistakes the topic files don't dwell on.
    
    ---
    
    ## 0. Golden Rules (Never Violate)
    
    These override everything in all sub-files:
    
    1. **Native APIs first** — WordPress core hook before plugin; Elementor API before template override.
    2. **Sanitize in, escape out** — Every input sanitized. Every output escaped. No exceptions.
    3. **Prefix everything** — All functions, classes, constants, hooks, and CSS classes use a project-specific prefix.
    4. **State your placement** — Every code response must declare exactly where the code lives.
    5. **No over-clarifying** — Only ask a clarification question if the missing info would materially change the code output. Otherwise, state your assumption and proceed.
    6. **NEVER hardcode visual settings in widgets** — Every visual property (colors, fonts, sizes,
       spacing, backgrounds, borders, shadows, alignment) MUST be exposed as a standard Elementor
       control in the editor panel. Users control appearance via the toolbar — not by editing code.
       See §5 "Mandatory Widget Controls" below for the required controls checklist.
    7. **Name for the directory from day one** — Naming is decided first and is effectively
       irreversible, so get it right before writing code. The plugin **slug/name must NOT start with
       a trademark you don't own** — `"CRM for WooCommerce"`, never `"WooCommerce CRM"` (Directory
       Guideline 17). The **text domain must exactly equal the plugin slug**. The wp.org slug is
       **permanent**, and a widget's `get_name()` is **sticky** (stored in every page's
       `_elementor_data` — renaming it breaks placed widgets). Choose the public name, slug, text
       domain, and code prefix (Rule #3) **together, once**. This is distinct from Rule #3: that
       governs internal code symbols; this governs the public identity. See **wp-org-guidelines.md**
       (Guidelines 12/16/17) and **field-notes.md** §1 (sticky `get_name()`).
    
    ---
    
    ## 1. Default Assumptions
    
    Quickly assess — **only ask if the answer would change the code**:
    
    | Info needed | Ask only if... |
    |---|---|
    | Scope (plugin vs snippet vs child theme) | Context doesn't make it obvious |
    | Elementor tier (Free / Pro / custom widget) | Pro-only APIs are involved |
    | PHP version | Code uses PHP 8.3+ features like typed class constants, or 8.4+ features like property hooks |
    | WooCommerce / ACF / WPML present | Integration with those systems is required |
    
    **Default stack when not stated** (full release-by-release history lives in **CHANGELOG.md** — keep volatile version-tracking out of this router):
    
    | Component | Version | Notes |
    |---|---|---|
    | **WordPress** | **7.1** | **"Mary Lou", released Aug 19, 2026** (7.0 "Armstrong" was May 20; the 7.0 branch ended at 7.0.4, Aug 12). Minimum PHP **7.4** (7.2/7.3 dropped — sites still on them stay pinned to 6.9.x). No multisite assumed. **7.1 makes the post editor iframe unconditional — see below.** |
    | **PHP** | **8.3** recommended | 7.4 = minimum. 8.4 / 8.5 = "beta support" (possible deprecation notices). 8.2 fully compatible but no longer the recommended default. |
    | **Elementor (free + Pro)** | **4.2+** | Separate plugins with **independent version numbers** — currently **free 4.2.3 / Pro 4.2.2** (both Aug 19, 2026). 4.0.0 (Mar 30, 2026) made the Atomic Editor stable + default for new installs; 4.2.0 (Jul 20) added Atomic Grid (free) and Atomic Loop (Pro). ⚠️ Elementor's header still reads **"Tested up to: 7.0.4"** — it has **not** declared WP 7.1 support. **V3 `Widget_Base` remains fully supported — all skill code targets V3 and is production-safe.** |
    | **WooCommerce** | **11.0+** | **11.0 (Aug 4, 2026) REMOVED the product editor beta** and bumped to **Action Scheduler 4.0.0** (breaking dedup change). HPOS default-on since 8.2; 10.7 disabled HPOS "sync on read"; 10.9 defers Store API draft-order creation — see woocommerce.md. |
    
    **Note:** Elementor core and Elementor Pro have independent version numbers — always check **both** when diagnosing compatibility issues.
    
    ### WordPress 7.1 — what changed for plugin / Elementor devs
    
    WP 7.1 **"Mary Lou"** shipped **August 19, 2026** (WordCamp US). Unlike 7.0, this release
    contains **four changes that can break a shipped plugin** — they are listed first. No PHP or
    WordPress minimum changed.
    
    **🔴 Breaking — audit your plugin for these:**
    
    1. **The post editor is now ALWAYS in an iframe** — including on sites registering legacy meta
       boxes, which was the last remaining escape hatch. Editor JavaScript that reaches for the
       global `document` / `window` now targets the **wrong document**. Use the canvas element's
       **`ownerDocument`** and **`defaultView`** instead:
       ```js
       // ❌ breaks in 7.1 — this is the OUTER document, not the editor canvas
       document.querySelector( '.my-block' ).classList.add( 'ready' );
    
       // ✅ resolve the document from a node you already own inside the canvas
       const doc  = myCanvasNode.ownerDocument;
       const view = doc.defaultView;                   // the iframe's `window`
       doc.querySelector( '.my-block' )?.classList.add( 'ready' );
       view.requestAnimationFrame( … );
       ```
       Injected `<style>`/`<link>` must go into the **canvas** document too, or it styles nothing.
       **Elementor's own editor is unaffected** (it is not the block editor) — this hits block
       registrations, meta boxes, and any block-editor integration your plugin ships.
    2. **`__next40pxDefaultSize` is now a no-op** on `@wordpress/components` — **remove the prop**
       entirely; there is no replacement. Form controls render at 40px unconditionally.
    3. **List-table markup changed:** the row header moved from the checkbox column to the **title**
       column. Any CSS/JS selector keyed on `th.check-column` (or assuming the row header's position)
       breaks — audit custom admin list tables and column callbacks.
    4. **jQuery UI updated to 1.14.2.** Test anything depending on jQuery UI behaviour or styling.
    
    **🟢 New and useful:**
    
    - **SVG Icon API** (public in 7.1) — register icons and whole collections, render server-side,
      read over REST: `wp_register_icon_collection()`, `wp_register_icon()`, `wp_get_icon()`.
      ⚠️ SVGs pass through a **conservative allowlist — only `<svg>`, `<path>` and `<polygon>` survive**.
      A `<g>`, `<circle>`, `<rect>` or `<use>` in your icon is stripped silently, so convert shapes to
      paths before registering. (This is a *core* API — Elementor widgets still ship their own SVGs;
      see `elementor-extending.md` §8 and the "don't `wp_kses()` inline SVG" rule in `field-notes.md` §6.)
    - **Abilities API matured:** `wp_get_abilities()` filtering, **execution-lifecycle hooks**, custom
      validation, a unified `public` exposure flag, and client-compatible JSON-Schema preparation.
      Registration timing rules from 7.0 are unchanged (see the 7.0 section below).
    - **Global Styles / `theme.json`:** responsive style variations with **configurable breakpoints**,
      pseudo-state styling (`:hover` / `:focus` / `:active`), and `text-shadow` support.
    - **Media:** client-side image processing in the browser via **WebAssembly** (compression/resizing
      no longer hit the server), infinite scroll in the Media Library, and registration of multiple
      image sizes at once. Multisite now enforces upload limits on **media sideloading**.
    - **Persistent admin bar** across editor screens — review any custom toolbar nodes for behaviour
      in the editor context.
    - **Design System:** semantic design tokens for theming admin UI (relevant if you build admin or
      editor panels — compare the Elementor panel tokens in `elementor-extending.md` §5).
    - **DataViews / DataForm** APIs matured for data-driven admin interfaces.
    - **React stays on 18.3** — React 19 was deferred again. Don't bundle `react/jsx-runtime`
      yourself, and avoid string refs / `defaultProps`, which fail under the experimental React 19 flag.
    - Conditional block-CSS loading can affect **remote content pulls** — verify if you render blocks
      outside a normal page request.
    
    _Sources: wordpress.org/news/2026/08/mary-lou/ · make.wordpress.org/core/2026/08/05/wordpress-7-1-field-guide/ ·
    developer.wordpress.org/news/2026/08/whats-new-for-developers-august-2026/_
    
    ### WordPress 7.0 — what changed for plugin / Elementor devs
    
    WP 7.0 "Armstrong" shipped **May 20, 2026** (delayed from the original April 9 target while
    the RTC storage layer was redesigned — see below). Everything below is **opt-in and
    non-breaking**; most plugin/Elementor work is unaffected. It remains relevant because 7.1
    changed none of it.
    
    - **Minimum PHP is now 7.4** (7.2/7.3 dropped). The skill's recommended baseline stays
      **PHP 8.3**. Bump your plugin's `Requires PHP` header to 7.4 only once you target WP 7.0+
      exclusively. No new DB minimum is enforced; `wordpress.org/about/requirements/` recommends
      **MariaDB 10.11+ or MySQL 8.0+** (the MariaDB floor was raised from 10.6; re-verified Aug 2026).
    - **Real-Time Collaboration (RTC):** simultaneous multi-author block editing (CRDT-based, via
      an HTTP-polling sync provider — not WebRTC). Data is stored in a **dedicated core database
      table**; an earlier `wp_post_meta` / `wp_sync_storage` design was rejected, and building the
      table is what pushed the release from April to May. **Plugin impact:** scope every
      `WP_Query` / `get_posts()` with an explicit `post_type` so internal core post types never
      leak into your results — do **not** hardcode any internal RTC type name. The
      `WP_ALLOW_COLLABORATION` constant lets hosts swap the sync transport.
    - **WP AI Client:** provider-agnostic PHP + JS AI API — `wp_ai_client_prompt( $prompt )->generate_text()`.
      Guard with `function_exists( 'wp_ai_client_prompt' )`.
    - **Abilities API:** `wp_register_ability()` (PHP, since WP 6.9) plus a JS counterpart in 7.0.
      Use `'meta' => ['show_in_rest' => true]` to expose via REST. Register ONLY inside the
      **`wp_abilities_api_init`** hook (core renamed the feature plugin's `abilities_api_init`;
      categories go on `wp_abilities_api_categories_init`) or you get `_doing_it_wrong`; a
      `category` is required and must already be registered. Calling it also sets your real
      `Requires at least` floor to **6.9** — Plugin Check errors on the header and
      `function_exists()` guards don't count (see `debugging.md` §1).
    - **Connectors UI** (Settings → Connectors) for managing AI provider credentials, and a
      **Command Palette** in wp-admin.
    - **Iframed editor** was still conditional in 7.0 — **7.1 makes it unconditional.** See the 7.1
      section above; `"apiVersion": 3` in `block.json` is now table stakes, not preparation.
    
    _Sources: make.wordpress.org/core/2026/01/09/dropping-support-for-php-7-2-and-7-3/ ·
    make.wordpress.org/core/2026/04/22/wordpress-7-0-release-party-updated-schedule/ ·
    wordpress.org/about/requirements/_
    
    > 📌 **PHP support labels (unchanged in WP 7.1):** PHP 7.4–8.3 fully compatible; **8.3 recommended**;
    > 8.4 (WP 6.7+) and 8.5 (WP 6.9+) carry a "beta support" label (possible deprecation notices).
    > Source: make.wordpress.org/core/handbook/references/php-compatibility-and-wordpress-versions/
    
    > ✅ **Elementor 4.x status (current: free 4.2.3 / Pro 4.2.2, both Aug 19, 2026):** Elementor
    > 4.0.0 (Mar 30, 2026, free + Pro) made the **Atomic Editor stable and the default for new
    > installs** and added Atomic Forms, Pro Interactions, and Component creation. Updating to 4.x
    > leaves **existing sites untouched** — V3 widgets and V4 Atomic Elements coexist on the same
    > page; Atomic features are toggled at WP Admin → Elementor → Editor → Settings.
    >
    > **There is still NO third-party API for building Atomic Elements, and Elementor has said so
    > explicitly** — they will not release one soon and *advise against* integrating with Atomic
    > internals until they announce it (elementor/elementor GitHub Discussion #32950). Atomic
    > Elements are documented only as a **data structure**
    > (developers.elementor.com/docs/data-structure/atomic-elements). So **continue using V3
    > `Widget_Base`** for all third-party widgets — it is the correct, production-safe API and all
    > skill code targets it. *(Re-verified Aug 23, 2026 — unchanged since the July check.)*
    >
    > ⚠️ **Elementor has not declared WP 7.1 compatibility yet.** As of free 4.2.3 / Pro 4.2.2 the
    > `Tested up to` header reads **7.0.4**, three days after WP 7.1 shipped. That is normal lag,
    > not a known incompatibility — but when a client reports editor breakage on a fresh 7.1 site,
    > check Elementor's current `Tested up to` before debugging your own code.
    >
    > **V4 Atomic Elements that now ship by default (awareness only — not third-party-buildable yet):**
    > Div Block & Flexbox Container (layout); **Atomic Grid** — advanced row/column layouts (free
    > 4.2.0, Jul 20, 2026); Atomic Heading, Paragraph, Image, Button, Video, SVG;
    > **Atomic Tabs**; **Atomic Forms** (Pro) with composable fields — Label, Input, Textarea,
    > Checkbox, Submit, plus Radio, Select, Date Picker, Time Picker, and File Upload (added in
    > Pro 4.1.0, May 26, 2026); and **Atomic Loop** — dynamic content layouts, the V4 counterpart
    > of the Pro Loop Grid (Pro 4.2.0, Jul 20, 2026). These are end-user elements; building
    > **custom** atomic elements still awaits the finalized V4 extension docs — keep targeting V3
    > `Widget_Base` until then.
    > Source: elementor.com/products/website-builder/v4-faq/ ·
    > developers.elementor.com/elementor-editor-4-0-developers-update/ ·
    > elementor.com/pro/changelog/ · github.com/elementor/elementor/releases
    
    > 🗓️ **Release-by-release history (betas, RCs, point releases) lives in `CHANGELOG.md`.**
    > Keep this router focused on durable guidance; update version facts in the table above
    > and in `CHANGELOG.md`, not scattered across the sub-files.
    ---
    
    ## 2. Architecture Decision Tree
    
    Run through this mentally before writing a single line:
    
    ```
    Does a WordPress core hook (add_action/add_filter) solve it?
      YES → Use the hook. No plugin needed. Place in child theme functions.php
            or Elementor Custom Code.
      NO  → Does Elementor's PHP/JS API solve it?
              YES → Extend via Elementor hooks, Dynamic Tags, or Widget_Base.
                    Register via elementor/widgets/register or elementor/dynamic_tags/register.
              NO  → Is this logic reusable across themes or sites?
                      YES → Scaffold a dedicated plugin (see scaffolding.md).
                      NO  → Child theme functions.php or Elementor Custom Code block.
    
    Is a WooCommerce override needed?
      → Use Elementor Loop Grid + custom query filter BEFORE touching template files.
      → Only override woocommerce/ templates as an absolute last resort.
    
    Is this a REST API endpoint?
      → Always register via register_rest_route() inside a plugin, never in functions.php.
    ```
    
    ---
    
    ## 3. Mandatory Output Format
    
    **Every single code response must follow this structure — no exceptions:**
    
    ```
    📍 PLACEMENT
    Exact file path or Elementor hook location.
    e.g. /wp-content/plugins/myplugin/includes/class-myplugin-hooks.php
         Elementor → Site Settings → Custom Code → wp_footer
    
    ⚙️ REQUIRES
    WordPress X.X+ | PHP X.X+ | Elementor Pro X.X+ | ACF X.X+ | WooCommerce X.X+
    (list only what the code actually depends on)
    
    💡 WHY THIS APPROACH
    One paragraph: which branch of the §2 decision tree was taken and why.
    
    📋 CODE
    Complete, commented, deployment-ready code block — no truncation, no omissions.
    
    🔧 INTEGRATION NOTES (include when relevant)
    Any manual steps required: flush rewrite rules, set Query ID in Elementor editor,
    activate plugin, clear Elementor cache, etc.
    ```
    
    ---
    
    ## 4. Quick Reference — Pattern Index
    
    | Task | Approach | Sub-file |
    |---|---|---|
    | Plugin scaffold | Singleton + hooks class + assets class + HPOS declaration | scaffolding.md |
    | Child theme setup | `wp_enqueue_style` parent + child in functions.php | scaffolding.md |
    | Custom Post Type + Taxonomy | `register_post_type()` + `register_taxonomy()` in plugin | scaffolding.md |
    | Secure AJAX handler | `wp_ajax_` hooks + nonce verify + `wp_send_json_*` | scaffolding.md |
    | PHP sanitization / escaping | `wp_unslash()` + `sanitize_*` + `esc_*` patterns | php-standards.md |
    | Transient caching | `get_transient` / `set_transient` | php-standards.md |
    | External API call + WP_Error | `wp_remote_get()` + `WP_Error` pattern | php-standards.md |
    | Admin settings page | Settings API + `register_setting` + `sanitize_callback` + `settings_fields` | wordpress-apis.md |
    | Store plugin options | Options API + explicit boolean `autoload` (WP 6.6+) | wordpress-apis.md |
    | Custom field exposed to REST / Elementor | `register_post_meta` + `show_in_rest` | wordpress-apis.md |
    | Scheduled / background task | WP-Cron (`wp_schedule_event`) + Action Scheduler for heavy jobs | wordpress-apis.md |
    | Create a custom DB table | `dbDelta()` + `$wpdb->prefix` + `get_charset_collate()`; strict formatting rules | mariadb.md |
    | "Specified key was too long" / emoji not saving | `utf8mb4` + index string columns at **`varchar(191)`** | mariadb.md |
    | Slow query / missing index | `EXPLAIN` (`type: ALL`, `key: NULL`, `Using filesort`) + leftmost-prefix rule | mariadb.md |
    | SQL that must run on MariaDB **and** MySQL | Plain standard SQL; JSON is stored/compared differently — don't query inside it | mariadb.md |
    | Make a plugin translatable | i18n functions + text-domain = slug + WP 6.7 `init`-timing rule | wordpress-apis.md |
    | Ship a translation for a wp.org-hosted plugin | `.pot` only in the zip; translate.wordpress.org → language packs (90% gate). **Never** bundle `.mo` / call `load_plugin_textdomain()` | wordpress-apis.md |
    | Style UI text for non-Latin locales | Caseless scripts + `text-transform`/`letter-spacing`; don't size to English string length | js-css-standards.md |
    | Test that translations actually load | Set the site locale before bootstrap; `unload_textdomain()` in WP-CLI; validate placeholders | debugging.md |
    | Deploy / tag a plugin release on wp.org SVN | `svn co` → copy to `trunk/` → `svn cp trunk tags/X.Y.Z` → `svn ci` (assets/ is a sibling) | svn/svn.md |
    | Any SVN task (checkout, commit, branch, merge, conflicts, repo admin) | Subversion sub-bundle | svn/svn.md |
    | WP 6.8 password hashing | `wp_check_password()` + `wp_password_needs_rehash()` | php-standards.md |
    | WP 6.8 app password / key hashing | `wp_fast_hash()` + `wp_verify_fast_hash()` (BLAKE2b) | php-standards.md |
    | JS standards + enqueue defer/async | IIFE + WP 6.3+ enqueue API | js-css-standards.md |
    | PHP → JS data passing | `wp_add_inline_script()` with `wp_json_encode()` | js-css-standards.md |
    | CSS BEM + design tokens | Scoped tokens, 8pt spacing, fluid type | js-css-standards.md |
    | Custom Elementor widget | `Widget_Base` + all required methods | elementor-patterns.md |
    | Elementor Dynamic Tag | `Tag` class + `elementor/dynamic_tags/register` | elementor-patterns.md |
    | Elementor Loop Grid query | `elementor/query/` filter | elementor-patterns.md |
    | ACF field in Elementor | Dynamic Tag extending `\Elementor\Core\DynamicTags\Tag` | elementor-patterns.md |
    | Elementor Pro Form action | `Action_Base` + field iteration | elementor-patterns.md |
    | Theme Builder custom condition | `Condition_Base` + `elementor/theme/register_conditions` | elementor-patterns.md |
    | Elementor Pro custom form field | `Field_Base` + `elementor_pro/forms/fields/register` | elementor-extending.md |
    | Theme Builder custom location | `register_location` + `elementor_theme_do_location()` | elementor-extending.md |
    | Add a control to a NATIVE Elementor widget | `elementor/element/{el}/{section}/before_section_end` | elementor-extending.md |
    | Filter a native widget's output | `elementor/widget/render_content` filter | elementor-extending.md |
    | Add a TAB to the editor panel (like an SEO plugin) | `elementorV2.editorElementsPanel.injectTab()` + `elementor/editor/before_enqueue_scripts` | elementor-extending.md |
    | WooCommerce HPOS compatibility | `FeaturesUtil::declare_compatibility()` + `wc_get_order()` | woocommerce.md |
    | WooCommerce loop | Loop Grid + `elementor/query/` filter | woocommerce.md |
    | Custom REST endpoint | `register_rest_route()` + schema callback in plugin | rest-api.md |
    | Off-canvas filter panel | BEM CSS + ARIA JS + Custom Code block | offcanvas-ui.md |
    | Performance checklist | Frontend + backend checklists | performance.md |
    | Accessibility checklist | WCAG 2.2 AA patterns | performance.md |
    | Elementor dependency check | `Requires Plugins: elementor` header + `did_action` fallback | scaffolding.md |
    | Widget with button/CTA | Controls + render matching native Button widget | widget-button.md |
    | Widget with container/layout | Controls + render matching native Container | widget-container.md |
    | Widget with image | `Group_Control_Image_Size` + `get_attachment_image_html()` | widget-image.md |
    | Widget with heading | TEXTAREA + header_size tag selector | widget-heading.md |
    | Widget with rich text | WYSIWYG + wp_kses_post output | widget-text-editor.md |
    | Widget with video embed | Source SELECT + overlay + aspect ratio | widget-video.md |
    | Widget rendering saved Elementor template | get_builder_content_for_display() + SELECT2 | widget-elementor-template.md |
    | Widget with PHP template file | load_template() + locate_template() strategies A/B/C | widget-php-template.md |
    | Widget with divider line | Style + width + optional text/icon element | widget-divider.md |
    | Widget with spacer gap | Single responsive SLIDER | widget-spacer.md |
    | Widget with single icon | ICONS control + size + color tabs | widget-icon.md |
    | Widget with icon + text box | Icon + title + description pattern | widget-icon-box.md |
    | Widget with image + text box | Image + title + description pattern | widget-image-box.md |
    | Widget with image grid | GALLERY control + Group_Control_Image_Size | widget-image-gallery.md |
    | Widget with image slider | GALLERY + Swiper + navigation controls | widget-image-carousel.md |
    | Widget with icon bullet list | REPEATER + icon + text + optional link | widget-icon-list.md |
    | Widget with animated counter | Number + prefix/suffix + duration | widget-counter.md |
    | Widget with progress bar | Percentage SLIDER + bar styling | widget-progress.md |
    | Widget with testimonial quote | Content + image + name + job title | widget-testimonial.md |
    | Widget with tabbed panels | REPEATER tabs + horizontal/vertical type | widget-tabs.md |
    | Widget with accordion | REPEATER + single-open collapse pattern | widget-accordion.md |
    | Widget with toggle panels | REPEATER + multi-open toggle pattern | widget-toggle.md |
    | Widget with social icons | REPEATER + brand icons + links | widget-social-icons.md |
    | Widget with alert/notice box | Type SELECT + title + description + dismiss | widget-alert.md |
    | Widget with audio player | SoundCloud URL + autoplay options | widget-audio.md |
    | Widget outputting shortcode | TEXTAREA + do_shortcode() | widget-shortcode.md |
    | Widget with raw HTML embed | CODE control + unescaped output | widget-html.md |
    | Widget as named anchor | TEXT ID + sanitize_html_class() | widget-menu-anchor.md |
    | Widget outputting sidebar | Registered sidebar SELECT + dynamic_sidebar() | widget-sidebar.md |
    | Widget with read more tag | No controls — WordPress $more global | widget-read-more.md |
    | Widget with Google Maps | Address TEXT + zoom SLIDER + iframe | widget-google-maps.md |
    | Widget with star rating display | Scale + rating number + icon style | widget-star-rating.md |
    | Widget with schema rating | Icon count + fractional rating + gap | widget-rating.md |
    | Widget with curved/path text | `<svg>` + `<textPath>` + unique path id | widget-text-path.md |
    | Nested Tabs / Accordion widget | `Widget_Nested_Base` + `print_child()` + container panels | widget-nested.md |
    
    ---
    
    ## 5. Mandatory Widget Controls — No Hardcoded Visuals (NEVER VIOLATE)
    
    > **This section is MANDATORY for every custom Elementor widget.** Whenever you build a widget,
    > every visual property must be an Elementor control — NEVER a hardcoded CSS value. Users
    > control appearance from the editor panel/toolbar, not by editing source code.
    
    ### The Rule
    
    **NEVER hardcode** any of the following in PHP `render()`, in static CSS, or in
    `content_template()` output:
    
    - Colors (text, background, border, shadow)
    - Typography (font family, size, weight, line-height, letter-spacing, transform)
    - Spacing (padding, margin, gap)
    - Sizing (width, height, min/max values)
    - Borders (style, width, color, radius)
    - Shadows (box-shadow, text-shadow)
    - Backgrounds (color, gradient, image)
    - Alignment / positioning
    - Opacity, transitions, hover effects
    
    **ALL** of the above must use Elementor controls with `selectors` that inject CSS dynamically.
    The only exceptions are structural CSS (display, position, overflow) required for the widget
    layout to function at all — and even these should use controls when there is a user-facing
    choice (e.g. flex-direction toggle).
    
    > ⚠️ **"No hardcoded visuals" does NOT mean "put a `default` on every control."**
    > Elementor emits a `default` exactly like a user-set value, so a colour default paints on every
    > install and clearing the swatch only restores it — the user cannot turn it off. **Elementor core
    > carries no `default` on colour controls at all** (`includes/widgets/heading.php`: `title_color`
    > uses a `'global'` reference; `title_hover_color` is entirely empty). Follow that split:
    > - **Sizes, spacing, alignment, toggles, tags, labels** → give a real `'default'`.
    > - **Colours** → leave empty. Put the resting look in your stylesheet's `var(--token, fallback)`
    >   and let the control override it. For **state** colours (hover / active / selected / current)
    >   also write a **direct CSS property**, never a custom property, and keep that state
    >   **colour-free in your CSS** — otherwise "empty" still paints the fallback.
    >
    > See `field-notes.md` §4 for the full trap, plus the theme-specificity and `accent-color` cases.
    
    ### Required Controls Checklist — Apply to Every Widget
    
    When building a widget, include ALL controls that apply to its visual elements.
    Use this checklist as a mandatory gate:
    
    | Visual property | Required Elementor control | Tab |
    |---|---|---|
    | **Text content** | `TEXT`, `TEXTAREA`, or `WYSIWYG` + `'dynamic' => ['active' => true]` | TAB_CONTENT |
    | **Typography** (any text element) | `add_group_control( Group_Control_Typography::get_type() )` | TAB_STYLE |
    | **Text color** | `COLOR` control with `selectors` | TAB_STYLE |
    | **Text alignment** | `add_responsive_control()` with `CHOOSE` (left/center/right/justify) | TAB_STYLE or TAB_CONTENT |
    | **Background** | `add_group_control( Group_Control_Background::get_type() )` | TAB_STYLE |
    | **Border** | `add_group_control( Group_Control_Border::get_type() )` | TAB_STYLE |
    | **Border radius** | `add_responsive_control()` with `DIMENSIONS` + `'selectors'` | TAB_STYLE |
    | **Box shadow** | `add_group_control( Group_Control_Box_Shadow::get_type() )` | TAB_STYLE |
    | **Text shadow** | `add_group_control( Group_Control_Text_Shadow::get_type() )` | TAB_STYLE |
    | **Padding** | `add_responsive_control()` with `DIMENSIONS` | TAB_STYLE |
    | **Margin** | `add_responsive_control()` with `DIMENSIONS` | TAB_STYLE |
    | **Width / Height** | `add_responsive_control()` with `SLIDER` | TAB_STYLE |
    | **Spacing / Gap** | `add_responsive_control()` with `SLIDER` | TAB_STYLE |
    | **Image** | `MEDIA` + `add_group_control( Group_Control_Image_Size::get_type() )` | TAB_CONTENT |
    | **CSS Filters** (if image/element) | `add_group_control( Group_Control_Css_Filter::get_type() )` | TAB_STYLE |
    | **Hover state** | Duplicate color/background/shadow controls inside `'section_style_hover'` with `selectors` targeting `:hover` | TAB_STYLE |
    | **Transition duration** | `SLIDER` (seconds) with `selectors => ['transition-duration']` | TAB_STYLE |
    | **Link** | `URL` control with `'dynamic' => ['active' => true]` | TAB_CONTENT |
    | **Icon** | `ICONS` control with `fa4compatibility` | TAB_CONTENT |
    | **HTML tag** | `SELECT` (h1–h6, div, span, p) | TAB_CONTENT |
    
    ### Example — Correct vs Incorrect
    
    ```php
    // ❌ WRONG — hardcoded color and font-size
    protected function render(): void {
        $settings = $this->get_settings_for_display();
        echo '<h2 style="color: #e94560; font-size: 24px;">'
            . esc_html( $settings['title'] ) . '</h2>';
    }
    
    // ✅ CORRECT — all visuals controlled from the panel via selectors
    protected function register_controls(): void {
        // ... Content section with title TEXT control ...
    
        $this->start_controls_section( 'section_title_style', [
            'label' => esc_html__( 'Title Style', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_control( 'title_color', [
            'label'     => esc_html__( 'Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'selectors' => [
                '{{WRAPPER}} .myplugin-widget__title' => 'color: {{VALUE}};',
            ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Typography::get_type(),
            [
                'name'     => 'title_typography',
                'selector' => '{{WRAPPER}} .myplugin-widget__title',
            ]
        );
    
        $this->add_responsive_control( 'title_align', [
            'label'   => esc_html__( 'Alignment', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::CHOOSE,
            'options' => [
                'left'   => [ 'title' => esc_html__( 'Left',   'myplugin' ), 'icon' => 'eicon-text-align-left' ],
                'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ],
                'right'  => [ 'title' => esc_html__( 'Right',  'myplugin' ), 'icon' => 'eicon-text-align-right' ],
            ],
            'selectors' => [
                '{{WRAPPER}} .myplugin-widget__title' => 'text-align: {{VALUE}};',
            ],
        ] );
    
        $this->add_responsive_control( 'title_spacing', [
            'label'      => esc_html__( 'Bottom Spacing', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::SLIDER,
            'size_units' => [ 'px', 'em', 'rem' ],
            'range'      => [ 'px' => [ 'min' => 0, 'max' => 100 ] ],
            'selectors'  => [
                '{{WRAPPER}} .myplugin-widget__title' => 'margin-bottom: {{SIZE}}{{UNIT}};',
            ],
        ] );
    
        $this->end_controls_section();
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
        // ✅ No inline styles — all visuals come from Elementor's selectors
        $this->add_render_attribute( 'title', 'class', 'myplugin-widget__title' );
        $this->add_inline_editing_attributes( 'title' );
        echo '<h2 ' . $this->get_render_attribute_string( 'title' ) . '>'
            . esc_html( $settings['title'] ) . '</h2>';
    }
    ```
    
    ### `add_render_attribute()` and `add_inline_editing_attributes()` — Always Use
    
    The official Elementor API for building HTML attributes is `$this->add_render_attribute()`.
    **Always use it** instead of manually concatenating class/id/aria attributes in `render()`.
    Pair with `$this->add_inline_editing_attributes()` for any text field that supports live
    editing in the Elementor editor panel.
    
    **Outputting the built attributes — pick by context:** use
    `get_render_attribute_string( 'key' )` when you are concatenating into a string (as in the
    `echo '<h2 ' . ... . '>'` example above), and `print_render_attribute_string( 'key' )` when
    you are echoing directly inside a `?> … <?php` HTML block (e.g.
    `<h2 <?php $this->print_render_attribute_string( 'title' ); ?>>`). Both are correct Elementor
    APIs — `print_*` simply echoes what `get_*` returns. The widget sub-files use the `print_*`
    form inside their HTML templates.
    
    Source: developers.elementor.com/docs/widgets/rendering-html-attribute/
    Source: developers.elementor.com/docs/widgets/rendering-inline-editing/
    
  • widget-accordion.md 14.5 KB
    # Widget Boilerplate — Accordion
    
    > **When to use this file:** Load whenever building a collapsible content widget where one panel opens at a time.
    > Verified against `elementor/includes/widgets/accordion.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    > Toggle widget is identical except `multiple_active` defaults to 'yes' — note at bottom.
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        // ── CONTENT ───────────────────────────────────────────────
        $this->start_controls_section( 'section_title', [
            'label' => esc_html__( 'Accordion', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $repeater = new \Elementor\Repeater();
    
        $repeater->add_control( 'tab_title', [
            'label'       => esc_html__( 'Title', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::TEXT,
            'default'     => esc_html__( 'Accordion Title', 'myplugin' ),
            'label_block' => true,
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $repeater->add_control( 'tab_content', [
            'label'      => esc_html__( 'Content', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::WYSIWYG,
            'default'    => esc_html__( 'Accordion Content', 'myplugin' ),
            'show_label' => false,
            'dynamic'    => [ 'active' => true ],
        ] );
    
        $this->add_control( 'tabs', [
            'label'       => esc_html__( 'Items', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::REPEATER,
            'fields'      => $repeater->get_controls(),
            'default'     => [
                [ 'tab_title' => esc_html__( 'Accordion #1', 'myplugin' ), 'tab_content' => esc_html__( 'Content here.', 'myplugin' ) ],
                [ 'tab_title' => esc_html__( 'Accordion #2', 'myplugin' ), 'tab_content' => esc_html__( 'Content here.', 'myplugin' ) ],
                [ 'tab_title' => esc_html__( 'Accordion #3', 'myplugin' ), 'tab_content' => esc_html__( 'Content here.', 'myplugin' ) ],
            ],
            'title_field' => '{{{ tab_title }}}',
        ] );
    
        $this->add_control( 'selected_icon', [
            'label'            => esc_html__( 'Icon', 'myplugin' ),
            'type'             => \Elementor\Controls_Manager::ICONS,
            'separator'        => 'before',
            'fa4compatibility' => 'icon',
            'default'          => [ 'value' => 'fas fa-plus',  'library' => 'fa-solid' ],
            'label_block'      => false,
            'skin'             => 'inline',
        ] );
    
        $this->add_control( 'selected_active_icon', [
            'label'            => esc_html__( 'Active Icon', 'myplugin' ),
            'type'             => \Elementor\Controls_Manager::ICONS,
            'fa4compatibility' => 'icon_active',
            'default'          => [ 'value' => 'fas fa-minus', 'library' => 'fa-solid' ],
            'label_block'      => false,
            'skin'             => 'inline',
            'condition'        => [ 'selected_icon[value]!' => '' ],
        ] );
    
        $this->add_control( 'title_html_tag', [
            'label'     => esc_html__( 'Title HTML Tag', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SELECT,
            'options'   => [ 'p' => 'p', 'div' => 'div', 'span' => 'span', 'h1' => 'H1', 'h2' => 'H2', 'h3' => 'H3', 'h4' => 'H4', 'h5' => 'H5', 'h6' => 'H6' ],
            'default'   => 'p',
            'separator' => 'before',
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE ─────────────────────────────────────────────────
        $this->start_controls_section( 'section_title_style', [
            'label' => esc_html__( 'Accordion', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_control( 'border_width', [
            'label'     => esc_html__( 'Border Width', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SLIDER,
            'range'     => [ 'px' => [ 'min' => 0, 'max' => 10 ] ],
            'default'   => [ 'size' => 1, 'unit' => 'px' ],
            'selectors' => [
                '{{WRAPPER}} .myplugin-accordion-item, {{WRAPPER}} .myplugin-tab-title, {{WRAPPER}} .myplugin-tab-content' => 'border-width: {{SIZE}}{{UNIT}};',
            ],
        ] );
    
        $this->add_control( 'border_color', [
            'label'     => esc_html__( 'Border Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'selectors' => [
                '{{WRAPPER}} .myplugin-accordion-item, {{WRAPPER}} .myplugin-tab-title, {{WRAPPER}} .myplugin-tab-content' => 'border-color: {{VALUE}};',
            ],
        ] );
    
        $this->end_controls_section();
    
        $this->start_controls_section( 'section_toggle_style_title', [
            'label' => esc_html__( 'Title', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_control( 'title_background', [
            'label'     => esc_html__( 'Background', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'selectors' => [ '{{WRAPPER}} .myplugin-tab-title' => 'background-color: {{VALUE}};' ],
        ] );
    
        $this->add_control( 'title_color', [
            'label'     => esc_html__( 'Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'selectors' => [ '{{WRAPPER}} .myplugin-tab-title' => 'color: {{VALUE}};' ],
            'global'    => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_PRIMARY ],
        ] );
    
        $this->add_control( 'title_active_color', [
            'label'     => esc_html__( 'Active Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'selectors' => [ '{{WRAPPER}} .myplugin-tab-title.myplugin-active' => 'color: {{VALUE}};' ],
            'global'    => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_ACCENT ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Typography::get_type(),
            [
                'name'     => 'title_typography',
                'selector' => '{{WRAPPER}} .myplugin-tab-title',
                'global'   => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_PRIMARY ],
            ]
        );
    
        $this->end_controls_section();
    
        $this->start_controls_section( 'section_toggle_style_content', [
            'label' => esc_html__( 'Content', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_control( 'content_background_color', [
            'label'     => esc_html__( 'Background', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'selectors' => [ '{{WRAPPER}} .myplugin-tab-content' => 'background-color: {{VALUE}};' ],
        ] );
    
        $this->add_control( 'content_color', [
            'label'     => esc_html__( 'Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'selectors' => [ '{{WRAPPER}} .myplugin-tab-content' => 'color: {{VALUE}};' ],
            'global'    => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_TEXT ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Typography::get_type(),
            [
                'name'     => 'content_typography',
                'selector' => '{{WRAPPER}} .myplugin-tab-content',
                'global'   => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_TEXT ],
            ]
        );
    
        $this->end_controls_section();
    }
    ```
    
    > ⚠️ **Keyboard interaction requirement (ARIA APG §3.1):** Elements with `role="button"` that
    > are NOT native `<button>` elements do NOT receive keyboard events for free. Your widget JS
    > **MUST** handle both `click` and `keydown` (Enter + Space) to activate panels, and `keydown`
    > (Up/Down arrows) to move focus between panel titles. Without this, keyboard-only users cannot
    > operate the accordion.
    > ```js
    > // Minimal keyboard handler — add inside your elementor/frontend/init addAction callback
    > scope.querySelectorAll( '.myplugin-tab-title' ).forEach( el => {
    >   el.addEventListener( 'keydown', e => {
    >     if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); el.click(); }
    >   } );
    > } );
    > ```
    > Source: w3.org/WAI/ARIA/apg/patterns/accordion/
    
    > **Toggle widget note:** Toggle is identical to Accordion with two differences:
    > 1. `get_name()` returns `'toggle'`, `get_title()` returns `'Toggle'`
    > 2. All panels can be open simultaneously (no mutual exclusion in JS)
    
    > **Remove checklist:**
    > - `selected_icon` + `selected_active_icon` → remove if no expand/collapse icons needed
    > - `title_html_tag` → remove if title tag is always fixed
    
    **render() + content_template() skeleton:**
    
    ```php
    // ✅ REQUIRED on every widget — removes the redundant inner wrapper div.
    // Return true ONLY if your render() output physically requires the inner wrapper.
    // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ Return false to enable output caching for static widgets.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
        $tabs     = $settings['tabs'];
    
        if ( empty( $tabs ) ) {
            return;
        }
    
        // ✅ get_id() replaces deprecated get_id_int() (deprecated since Elementor 3.1.0)
        // Source: developers.elementor.com/v3-5-planned-deprecations/
        $id_int = substr( $this->get_id(), 0, 3 );
        ?>
        <div class="myplugin-accordion">
            <?php foreach ( $tabs as $index => $tab ) :
                $tab_count  = $index + 1;
                $tab_id     = 'myplugin-accordion-' . $id_int . $tab_count;
                $tag        = \Elementor\Utils::validate_html_tag( $settings['title_html_tag'] );
                $is_active  = 1 === $tab_count;
            ?>
            <?php
                // ✅ aria-controls must point to the content panel ID — required by ARIA APG Accordion pattern.
                // Without it, screen readers announce expand/collapse state but cannot navigate to the region.
                // Source: w3.org/WAI/ARIA/apg/patterns/accordion/
                $content_id = 'myplugin-content-' . $id_int . $tab_count;
            ?>
            <div class="myplugin-accordion-item elementor-repeater-item-<?php echo esc_attr( $tab['_id'] ); ?>">
                <<?php echo esc_attr( $tag ); ?>
                    id="<?php echo esc_attr( $tab_id ); ?>"
                    class="myplugin-tab-title<?php echo $is_active ? ' myplugin-active' : ''; ?>"
                    aria-expanded="<?php echo $is_active ? 'true' : 'false'; ?>"
                    aria-controls="<?php echo esc_attr( $content_id ); ?>"
                    role="button"
                    tabindex="0">
                    <?php
                    // ✅ BUG FIX: selected_icon and selected_active_icon are WIDGET-LEVEL controls,
                    // not per-item repeater controls. Check $settings, not $tab.
                    // Using $tab['selected_icon'] would always be empty — it's not in the repeater.
                    if ( ! empty( $settings['selected_icon']['value'] ) ) : ?>
                        <span class="myplugin-accordion-icon myplugin-accordion-icon--<?php echo $is_active ? 'opened' : 'closed'; ?>">
                            <span class="myplugin-accordion-icon-opened">
                                <?php \Elementor\Icons_Manager::render_icon( $settings['selected_active_icon'], [ 'aria-hidden' => 'true' ] ); ?>
                            </span>
                            <span class="myplugin-accordion-icon-closed">
                                <?php \Elementor\Icons_Manager::render_icon( $settings['selected_icon'], [ 'aria-hidden' => 'true' ] ); ?>
                            </span>
                        </span>
                    <?php endif; ?>
                    <span class="myplugin-accordion-title"><?php echo esc_html( $tab['tab_title'] ); ?></span>
                </<?php echo esc_attr( $tag ); ?>>
                <div id="<?php echo esc_attr( $content_id ); ?>"
                     class="myplugin-tab-content<?php echo $is_active ? ' myplugin-active' : ''; ?>"
                     role="region"
                     aria-labelledby="<?php echo esc_attr( $tab_id ); ?>">
                    <?php echo wp_kses_post( $tab['tab_content'] ); ?>
                </div>
            </div>
            <?php endforeach; ?>
        </div>
        <?php
    }
    
    protected function content_template(): void {
        ?>
        <#
        if ( ! settings.tabs || ! settings.tabs.length ) { return; }
        var tag = elementor.helpers.validateHTMLTag( settings.title_html_tag );
        #>
        <div class="myplugin-accordion">
            <# _.each( settings.tabs, function( tab, index ) {
                var isActive = 0 === index;
                var tabId    = 'myplugin-accordion-' + index;
                var contentId = 'myplugin-content-' + index;
                // ✅ BUG FIX: Both icons are WIDGET-LEVEL controls (settings.*), not per-item (tab.*).
                // tab.selected_icon does not exist — it is not in the repeater fields.
                // Read settings.selected_icon for the closed icon, settings.selected_active_icon for open.
                var iconHTML = settings.selected_icon && settings.selected_icon.value
                    ? elementor.helpers.renderIcon( view, settings.selected_icon, { 'aria-hidden': true }, 'i', 'object' )
                    : null;
                var activeIconHTML = settings.selected_active_icon && settings.selected_active_icon.value
                    ? elementor.helpers.renderIcon( view, settings.selected_active_icon, { 'aria-hidden': true }, 'i', 'object' )
                    : null;
            #>
            <div class="myplugin-accordion-item">
                <{{{ tag }}} id="{{ tabId }}"
                    class="myplugin-tab-title{{ isActive ? ' myplugin-active' : '' }}"
                    aria-expanded="{{ isActive ? 'true' : 'false' }}"
                    aria-controls="{{ contentId }}"
                    role="button" tabindex="0">
                    <# if ( iconHTML ) { #>
                        <span class="myplugin-accordion-icon">
                            <span class="myplugin-accordion-icon-opened">{{{ activeIconHTML ? activeIconHTML.value : '' }}}</span>
                            <span class="myplugin-accordion-icon-closed">{{{ iconHTML.value }}}</span>
                        </span>
                    <# } #>
                    <span class="myplugin-accordion-title">{{ tab.tab_title }}</span>
                </{{{ tag }}}>
                <div id="{{ contentId }}"
                     class="myplugin-tab-content{{ isActive ? ' myplugin-active' : '' }}"
                     role="region"
                     aria-labelledby="{{ tabId }}">
                    {{{ tab.tab_content }}}
                </div>
            </div>
            <# } ); #>
        </div>
        <?php
    }
    ```
    
  • widget-alert.md 7.9 KB
    # Widget Boilerplate — Alert
    
    > **When to use this file:** Load whenever building a colored notice/alert box widget.
    > Verified against `elementor/includes/widgets/alert.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        $this->start_controls_section( 'section_alert', [
            'label' => esc_html__( 'Alert', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $this->add_control( 'alert_type', [
            'label'        => esc_html__( 'Type', 'myplugin' ),
            'type'         => \Elementor\Controls_Manager::SELECT,
            'default'      => 'info',
            'options'      => [
                'info'    => esc_html__( 'Info',    'myplugin' ),
                'success' => esc_html__( 'Success', 'myplugin' ),
                'warning' => esc_html__( 'Warning', 'myplugin' ),
                'danger'  => esc_html__( 'Danger',  'myplugin' ),
            ],
            // ✅ The selected type drives a `.myplugin-alert--{type}` modifier class on the alert
            // element in render() (see below) + the default CSS at the bottom of this file.
            // Do NOT use prefix_class => 'elementor-alert-': that writes the class to the widget
            // WRAPPER and depends on Elementor's native alert CSS being loaded, which is NOT
            // guaranteed for a custom widget — the colors would silently never appear.
        ] );
    
        $this->add_control( 'alert_title', [
            'label'       => esc_html__( 'Title & Description', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::TEXT,
            'placeholder' => esc_html__( 'Your Title', 'myplugin' ),
            'default'     => esc_html__( 'This is an Alert', 'myplugin' ),
            'label_block' => true,
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $this->add_control( 'alert_description', [
            'label'       => '',
            'type'        => \Elementor\Controls_Manager::TEXTAREA,
            'default'     => esc_html__( 'I am a description. Click the edit button to change this text.', 'myplugin' ),
            'placeholder' => esc_html__( 'Your Description', 'myplugin' ),
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $this->add_control( 'show_dismiss', [
            'label'   => esc_html__( 'Dismiss Button', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SELECT,
            'default' => 'hide',
            'options' => [
                'show' => esc_html__( 'Show', 'myplugin' ),
                'hide' => esc_html__( 'Hide', 'myplugin' ),
            ],
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE ─────────────────────────────────────────────────
        $this->start_controls_section( 'section_type_style', [
            'label' => esc_html__( 'Alert Box', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        // ✅ Background + Text color controls OVERRIDE the per-type CSS defaults — so visuals
        // remain fully user-controllable (SKILL.md §0 Golden Rule #6). Leave empty to keep the
        // type preset; set a value to override it.
        $this->add_control( 'background_color', [
            'label'     => esc_html__( 'Background Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'selectors' => [ '{{WRAPPER}} .myplugin-alert' => 'background-color: {{VALUE}};' ],
        ] );
    
        $this->add_control( 'alert_text_color', [
            'label'     => esc_html__( 'Text Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'selectors' => [ '{{WRAPPER}} .myplugin-alert' => 'color: {{VALUE}};' ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Border::get_type(),
            [ 'name' => 'alert_border', 'selector' => '{{WRAPPER}} .myplugin-alert' ]
        );
    
        $this->add_responsive_control( 'border_radius', [
            'label'      => esc_html__( 'Border Radius', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::DIMENSIONS,
            'size_units' => [ 'px', '%', 'em', 'rem' ],
            'selectors'  => [
                '{{WRAPPER}} .myplugin-alert' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};',
            ],
        ] );
    
        $this->end_controls_section();
    }
    ```
    
    **render() skeleton:**
    
    ```php
    
    // ✅ REQUIRED on every widget — removes the redundant inner wrapper div.
    // Return true ONLY if your render() output physically requires the inner wrapper.
    // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ Return false to enable output caching for static widgets (same HTML for all users).
    // Return true if output varies per user, session, time, or random values.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
        // ✅ Type modifier class on the alert element itself — this is what makes the
        // info/success/warning/danger colors actually apply (see CSS block below).
        $type = ! empty( $settings['alert_type'] ) ? $settings['alert_type'] : 'info';
        ?>
        <div class="myplugin-alert myplugin-alert--<?php echo esc_attr( $type ); ?>" role="alert">
            <?php if ( 'show' === $settings['show_dismiss'] ) : ?>
                <button type="button" class="myplugin-alert-dismiss" aria-label="<?php esc_attr_e( 'Close', 'myplugin' ); ?>">
                    &times;
                </button>
            <?php endif; ?>
            <?php if ( ! empty( $settings['alert_title'] ) ) : ?>
                <span class="myplugin-alert-title"><?php echo esc_html( $settings['alert_title'] ); ?></span>
            <?php endif; ?>
            <?php if ( ! empty( $settings['alert_description'] ) ) : ?>
                <span class="myplugin-alert-description"><?php echo wp_kses_post( $settings['alert_description'] ); ?></span>
            <?php endif; ?>
        </div>
        <?php
    }
    ```
    
    ---
    
    
    
    **content_template() skeleton:**
    
    ```php
    protected function content_template(): void {
        ?>
        <# if ( settings.alert_title || settings.alert_description ) {
            var type = settings.alert_type || 'info';
        #>
        <div class="myplugin-alert myplugin-alert--{{ type }}" role="alert">
            <# if ( 'show' === settings.show_dismiss ) { #>
                <button type="button" class="myplugin-alert-dismiss">&times;</button>
            <# } #>
            <# if ( settings.alert_title ) { #>
                <span class="myplugin-alert-title">{{ settings.alert_title }}</span>
            <# } #>
            <# if ( settings.alert_description ) { #>
                <span class="myplugin-alert-description">{{ settings.alert_description }}</span>
            <# } #>
        </div>
        <# } #>
        <?php
    }
    ```
    
    ---
    
    **Default styles** (`assets/css/myplugin-alert.css` — register it and declare via
    `get_style_depends()`; see `elementor-patterns.md` Step 2). These are sensible **defaults**;
    the Style-tab Background / Text / Border controls override them, so visuals stay user-controlled.
    
    ```css
    /* Baseline layout + per-type default colors (mirrors how Elementor's own alert ships presets). */
    .myplugin-alert {
        position: relative;
        padding: 15px;
        border-left: 5px solid transparent;
    }
    .myplugin-alert--info    { background: #d9edf7; border-color: #5bc0de; color: #31708f; }
    .myplugin-alert--success { background: #dff0d8; border-color: #5cb85c; color: #3c763d; }
    .myplugin-alert--warning { background: #fcf8e3; border-color: #f0ad4e; color: #8a6d3b; }
    .myplugin-alert--danger  { background: #f2dede; border-color: #d9534f; color: #a94442; }
    .myplugin-alert-title       { display: block; font-weight: 700; }
    .myplugin-alert-description { display: block; }
    .myplugin-alert-dismiss {
        position: absolute;
        top: 10px;
        inset-inline-end: 12px;
        padding: 0;
        background: none;
        border: 0;
        font-size: 18px;
        line-height: 1;
        color: inherit;
        cursor: pointer;
    }
    ```
    
  • widget-audio.md 3.5 KB
    # Widget Boilerplate — Audio
    
    > **When to use this file:** Load whenever building a widget that embeds SoundCloud or self-hosted audio.
    > Verified against `elementor/includes/widgets/audio.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        $this->start_controls_section( 'section_audio', [
            'label' => esc_html__( 'Audio', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $this->add_control( 'link', [
            'label'       => esc_html__( 'Link', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::TEXT,
            'placeholder' => esc_html__( 'Enter your SoundCloud URL', 'myplugin' ),
            'default'     => 'https://soundcloud.com/shawn-wasabi/maple-syrup',
            'label_block' => true,
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $this->add_control( 'visual', [
            'label'   => esc_html__( 'Visual Player', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SWITCHER,
            'default' => 'yes',
        ] );
    
        $this->add_control( 'sc_auto_play', [
            'label'   => esc_html__( 'Autoplay', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SWITCHER,
            'default' => '',
        ] );
    
        $this->end_controls_section();
    }
    ```
    
    ---
    
    
    **render() + content_template() skeleton:**
    
    ```php
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
    
        if ( empty( $settings['link'] ) ) {
            return;
        }
    
        // ✅ Use wp_oembed_get() to convert the SoundCloud URL into an embed iframe.
        // Falls back gracefully — returns false if oEmbed fails (e.g. private track).
        // ⚠️ Pass the RAW URL — do NOT wrap in esc_url() here. esc_url() HTML-encodes
        // ampersands (&→&amp;) which corrupts the URL before wp_oembed_get() can parse it.
        // Sanitize with sanitize_url() instead, which strips unsafe characters without encoding.
        $oembed_html = wp_oembed_get( sanitize_url( $settings['link'] ) );
    
        if ( ! $oembed_html ) {
            if ( \Elementor\Plugin::$instance->editor && \Elementor\Plugin::$instance->editor->is_edit_mode() ) {
                echo '<p>' . esc_html__( 'Could not load audio. Check the URL.', 'myplugin' ) . '</p>';
            }
            return;
        }
    
        // ✅ DO NOT use wp_kses_post() on oEmbed HTML.
        // wp_kses_post() uses wp_kses_allowed_html('post') which does NOT include <iframe>.
        // SoundCloud oEmbed returns an <iframe> — wp_kses_post() would strip it entirely,
        // producing empty output with no error. wp_oembed_get() returns WordPress-generated
        // HTML that has already been processed through WordPress's own oEmbed stack (trusted).
        // This matches Elementor's native Audio widget (elementor/includes/widgets/audio.php).
        // Source: developer.wordpress.org/reference/functions/wp_kses_allowed_html/ ('post' context)
        echo '<div class="myplugin-audio">' . $oembed_html . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- trusted WordPress-generated oEmbed; wp_kses_post strips <iframe>
    }
    
    protected function content_template(): void {
        ?>
        <#
        if ( ! settings.link ) { return; }
        #>
        <div class="myplugin-audio myplugin-audio--placeholder">
            <p><?php echo esc_html__( 'Audio preview available on the frontend.', 'myplugin' ); ?></p>
        </div>
        <?php
    }
    ```
    
  • widget-button.md 17.8 KB
    # Widget Boilerplate — Button
    
    > **When to use this file:** Load this boilerplate whenever building a widget that has a
    > primary clickable call-to-action — a button, submit trigger, or any linked element with
    > text and optional icon.
    >
    > Verified against `elementor/includes/widgets/traits/button-trait.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    > Control IDs, defaults, selectors, and order match Elementor's native Button widget exactly.
    
    ---
    
    ### Button Widget Boilerplate
    
    Verified against `elementor/includes/widgets/traits/button-trait.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    
    ```php
    protected function register_controls(): void {
    
        // =========================================================
        // TAB: CONTENT
        // =========================================================
    
        $this->start_controls_section( 'section_button', [
            'label' => esc_html__( 'Button', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        // Type — prefix_class writes e.g. 'elementor-button-info' on the widget wrapper.
        // Empty string = Default (no extra class added).
        $this->add_control( 'button_type', [
            'label'        => esc_html__( 'Type', 'myplugin' ),
            'type'         => \Elementor\Controls_Manager::SELECT,
            'default'      => '',
            'options'      => [
                ''        => esc_html__( 'Default', 'myplugin' ),
                'info'    => esc_html__( 'Info',    'myplugin' ),
                'success' => esc_html__( 'Success', 'myplugin' ),
                'warning' => esc_html__( 'Warning', 'myplugin' ),
                'danger'  => esc_html__( 'Danger',  'myplugin' ),
            ],
            'prefix_class' => 'elementor-button-',
        ] );
    
        // ✅ Elementor native default is 'Click me' — match exactly
        $this->add_control( 'text', [
            'label'       => esc_html__( 'Text', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::TEXT,
            'default'     => esc_html__( 'Click me', 'myplugin' ),
            'placeholder' => esc_html__( 'Click me', 'myplugin' ),
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $this->add_control( 'link', [
            'label'       => esc_html__( 'Link', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::URL,
            'placeholder' => esc_html__( 'https://your-link.com', 'myplugin' ),
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $this->add_control( 'selected_icon', [
            'label'            => esc_html__( 'Icon', 'myplugin' ),
            'type'             => \Elementor\Controls_Manager::ICONS,
            'default'          => [ 'value' => '', 'library' => '' ],
            'label_block'      => true,
            'fa4compatibility' => 'icon',
        ] );
    
        $this->add_control( 'icon_align', [
            'label'     => esc_html__( 'Icon Position', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SELECT,
            'default'   => 'left',
            'options'   => [
                'left'  => esc_html__( 'Before', 'myplugin' ),
                'right' => esc_html__( 'After',  'myplugin' ),
            ],
            'condition' => [ 'selected_icon[value]!' => '' ],
        ] );
    
        $this->add_control( 'icon_indent', [
            'label'     => esc_html__( 'Icon Spacing', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SLIDER,
            'default'   => [ 'size' => 8, 'unit' => 'px' ],
            'range'     => [ 'px' => [ 'min' => 0, 'max' => 50 ] ],
            'condition' => [ 'selected_icon[value]!' => '' ],
            'selectors' => [
                // ✅ These class names match Elementor's native button HTML output
                '{{WRAPPER}} .elementor-button .elementor-align-icon-right' => 'margin-left: {{SIZE}}{{UNIT}};',
                '{{WRAPPER}} .elementor-button .elementor-align-icon-left'  => 'margin-right: {{SIZE}}{{UNIT}};',
            ],
        ] );
    
        $this->add_control( 'button_id', [
            'label'       => esc_html__( 'Button ID', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::TEXT,
            'default'     => '',
            'description' => esc_html__( 'Please make sure the ID is unique and not used elsewhere on the page. This field allows A-z, 0-9 & underscore chars without spaces.', 'myplugin' ),
            'separator'   => 'before',
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $this->end_controls_section();
    
        // =========================================================
        // TAB: STYLE
        // =========================================================
    
        $this->start_controls_section( 'section_style', [
            'label' => esc_html__( 'Button', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        // ✅ Elementor native uses prefix_class for alignment, not a CSS selector.
        // 'elementor%s-button-align-' resolves to e.g. 'elementor-button-align-center'
        // on desktop and 'elementor-tablet-button-align-center' on tablet.
        $this->add_responsive_control( 'align', [
            'label'        => esc_html__( 'Alignment', 'myplugin' ),
            'type'         => \Elementor\Controls_Manager::CHOOSE,
            'options'      => [
                'left'    => [ 'title' => esc_html__( 'Left',      'myplugin' ), 'icon' => 'eicon-text-align-left'    ],
                'center'  => [ 'title' => esc_html__( 'Center',    'myplugin' ), 'icon' => 'eicon-text-align-center'  ],
                'right'   => [ 'title' => esc_html__( 'Right',     'myplugin' ), 'icon' => 'eicon-text-align-right'   ],
                'justify' => [ 'title' => esc_html__( 'Justified', 'myplugin' ), 'icon' => 'eicon-text-align-justify' ],
            ],
            'prefix_class' => 'elementor%s-button-align-',
            // ✅ No 'default' — empty inherits document flow without writing inline style
        ] );
    
        // ✅ Size — uses prefix_class so Elementor's own CSS defines the preset padding values
        $this->add_control( 'size', [
            'label'          => esc_html__( 'Size', 'myplugin' ),
            'type'           => \Elementor\Controls_Manager::SELECT,
            'default'        => 'sm',
            'options'        => [
                'xs' => esc_html__( 'Extra Small', 'myplugin' ),
                'sm' => esc_html__( 'Small',       'myplugin' ),
                'md' => esc_html__( 'Medium',      'myplugin' ),
                'lg' => esc_html__( 'Large',       'myplugin' ),
                'xl' => esc_html__( 'Extra Large', 'myplugin' ),
            ],
            'prefix_class'   => 'elementor-size-',
            'style_transfer' => true,
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Typography::get_type(),
            [
                'name'     => 'typography',
                'global'   => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_ACCENT ],
                // ✅ Dual selector — targets both <a> and <button> render() output
                'selector' => '{{WRAPPER}} a.elementor-button, {{WRAPPER}} .elementor-button',
            ]
        );
    
        // ✅ text_shadow comes BEFORE the Normal/Hover tabs — matches Elementor native order
        $this->add_group_control(
            \Elementor\Group_Control_Text_Shadow::get_type(),
            [
                'name'     => 'text_shadow',
                'selector' => '{{WRAPPER}} a.elementor-button, {{WRAPPER}} .elementor-button',
            ]
        );
    
        // ── Normal / Hover tabs ───────────────────────────────────
        $this->start_controls_tabs( 'tabs_button_style' );
    
            $this->start_controls_tab( 'tab_button_normal', [
                'label' => esc_html__( 'Normal', 'myplugin' ),
            ] );
    
                $this->add_control( 'button_text_color', [
                    'label'     => esc_html__( 'Text Color', 'myplugin' ),
                    'type'      => \Elementor\Controls_Manager::COLOR,
                    'default'   => '',
                    // ✅ Dual selector matches both <a> and <button> output
                    'selectors' => [
                        '{{WRAPPER}} a.elementor-button, {{WRAPPER}} .elementor-button' => 'fill: {{VALUE}}; color: {{VALUE}};',
                    ],
                ] );
    
                $this->add_group_control(
                    \Elementor\Group_Control_Background::get_type(),
                    [
                        'name'           => 'background',
                        'types'          => [ 'classic', 'gradient' ],
                        'exclude'        => [ 'image' ],
                        'selector'       => '{{WRAPPER}} a.elementor-button, {{WRAPPER}} .elementor-button',
                        'fields_options' => [
                            'background' => [ 'default' => 'classic' ],
                        ],
                    ]
                );
    
            $this->end_controls_tab();
    
            $this->start_controls_tab( 'tab_button_hover', [
                'label' => esc_html__( 'Hover', 'myplugin' ),
            ] );
    
                $this->add_control( 'hover_color', [
                    'label'     => esc_html__( 'Text Color', 'myplugin' ),
                    'type'      => \Elementor\Controls_Manager::COLOR,
                    'default'   => '',
                    'selectors' => [
                        '{{WRAPPER}} a.elementor-button:hover, {{WRAPPER}} .elementor-button:hover, {{WRAPPER}} a.elementor-button:focus, {{WRAPPER}} .elementor-button:focus' => 'color: {{VALUE}};',
                        '{{WRAPPER}} a.elementor-button:hover svg, {{WRAPPER}} .elementor-button:hover svg' => 'fill: {{VALUE}};',
                    ],
                ] );
    
                $this->add_group_control(
                    \Elementor\Group_Control_Background::get_type(),
                    [
                        'name'     => 'button_background_hover',
                        'types'    => [ 'classic', 'gradient' ],
                        'exclude'  => [ 'image' ],
                        'selector' => '{{WRAPPER}} a.elementor-button:hover, {{WRAPPER}} .elementor-button:hover, {{WRAPPER}} a.elementor-button:focus, {{WRAPPER}} .elementor-button:focus',
                    ]
                );
    
                $this->add_control( 'button_hover_border_color', [
                    'label'     => esc_html__( 'Border Color', 'myplugin' ),
                    'type'      => \Elementor\Controls_Manager::COLOR,
                    'default'   => '',
                    'condition' => [ 'border_border!' => '' ],
                    'selectors' => [
                        '{{WRAPPER}} a.elementor-button:hover, {{WRAPPER}} .elementor-button:hover, {{WRAPPER}} a.elementor-button:focus, {{WRAPPER}} .elementor-button:focus' => 'border-color: {{VALUE}};',
                    ],
                ] );
    
                // ⛔ DO NOT include hover_animation by default.
                // It loads Elementor's animation CSS library on every page using this widget.
                // Only uncomment if the client explicitly requires animation support.
                //
                // $this->add_control( 'hover_animation', [
                //     'label' => esc_html__( 'Hover Animation', 'myplugin' ),
                //     'type'  => \Elementor\Controls_Manager::HOVER_ANIMATION,
                // ] );
    
            $this->end_controls_tab();
    
        $this->end_controls_tabs();
    
        $this->add_group_control(
            \Elementor\Group_Control_Border::get_type(),
            [
                'name'      => 'border',
                'selector'  => '{{WRAPPER}} a.elementor-button, {{WRAPPER}} .elementor-button',
                'separator' => 'before',
            ]
        );
    
        $this->add_responsive_control( 'border_radius', [
            'label'      => esc_html__( 'Border Radius', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::DIMENSIONS,
            'size_units' => [ 'px', '%', 'em', 'rem' ],
            'selectors'  => [
                '{{WRAPPER}} a.elementor-button, {{WRAPPER}} .elementor-button' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};',
            ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Box_Shadow::get_type(),
            [
                'name'     => 'button_box_shadow',
                'selector' => '{{WRAPPER}} a.elementor-button, {{WRAPPER}} .elementor-button',
            ]
        );
    
        // ✅ Elementor native control ID is 'text_padding' not 'padding'
        $this->add_responsive_control( 'text_padding', [
            'label'      => esc_html__( 'Padding', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::DIMENSIONS,
            'size_units' => [ 'px', '%', 'em', 'rem' ],
            'selectors'  => [
                '{{WRAPPER}} a.elementor-button, {{WRAPPER}} .elementor-button' => 'padding: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};',
            ],
            'separator'  => 'before',
        ] );
    
        $this->end_controls_section();
    }
    ```
    
    **Matching render() skeleton:**
    
    ```php
    
    // ✅ REQUIRED on every widget — removes the redundant inner wrapper div.
    // Return true ONLY if your render() output physically requires the inner wrapper.
    // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ Return false to enable output caching for static widgets (same HTML for all users).
    // Return true if output varies per user, session, time, or random values.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
    
        if ( empty( $settings['text'] ) && empty( $settings['selected_icon']['value'] ) ) {
            return;
        }
    
        $this->add_render_attribute( 'wrapper', 'class', 'elementor-button-wrapper' );
    
        $this->add_render_attribute( 'button', [
            'class' => 'elementor-button',
            // ✅ Do NOT add role='button' here. When $tag resolves to <button>, the button
            // element already has an implicit role of 'button' per ARIA spec — adding it
            // explicitly is redundant. When $tag resolves to <a>, role='button' would
            // override the correct implicit role='link' semantic, breaking accessibility.
            // Source: w3.org/TR/wai-aria-1.2/#button / w3.org/TR/html-aam-1.0/#a-element
        ] );
    
        if ( ! empty( $settings['size'] ) ) {
            $this->add_render_attribute( 'button', 'class', 'elementor-size-' . $settings['size'] );
        }
    
        if ( ! empty( $settings['button_id'] ) ) {
            $this->add_render_attribute( 'button', 'id', sanitize_key( $settings['button_id'] ) );
        }
    
        // ⛔ hover_animation excluded by default — uncomment if control is re-enabled:
        // if ( ! empty( $settings['hover_animation'] ) ) {
        //     $this->add_render_attribute( 'button', 'class', 'elementor-animation-' . $settings['hover_animation'] );
        // }
    
        if ( ! empty( $settings['link']['url'] ) ) {
            $this->add_link_attributes( 'button', $settings['link'] );
            $this->add_render_attribute( 'button', 'class', 'elementor-button-link' );
            $tag = 'a';
        } else {
            $tag = 'button';
        }
        ?>
        <div <?php $this->print_render_attribute_string( 'wrapper' ); ?>>
            <<?php echo esc_attr( $tag ); ?> <?php $this->print_render_attribute_string( 'button' ); ?>>
                <span class="elementor-button-content-wrapper">
                    <?php if ( ! empty( $settings['selected_icon']['value'] ) && 'left' === $settings['icon_align'] ) : ?>
                        <span class="elementor-button-icon elementor-align-icon-left">
                            <?php \Elementor\Icons_Manager::render_icon( $settings['selected_icon'], [ 'aria-hidden' => 'true' ] ); ?>
                        </span>
                    <?php endif; ?>
    
                    <?php if ( ! empty( $settings['text'] ) ) : ?>
                        <span class="elementor-button-text">
                            <?php echo esc_html( $settings['text'] ); ?>
                        </span>
                    <?php endif; ?>
    
                    <?php if ( ! empty( $settings['selected_icon']['value'] ) && 'right' === $settings['icon_align'] ) : ?>
                        <span class="elementor-button-icon elementor-align-icon-right">
                            <?php \Elementor\Icons_Manager::render_icon( $settings['selected_icon'], [ 'aria-hidden' => 'true' ] ); ?>
                        </span>
                    <?php endif; ?>
                </span>
            </<?php echo esc_attr( $tag ); ?>>
        </div>
        <?php
    }
    
    protected function content_template(): void {
        ?>
        <#
        var iconHTML  = elementor.helpers.renderIcon( view, settings.selected_icon, { 'aria-hidden': true }, 'i', 'object' );
        var hasIcon   = iconHTML && iconHTML.rendered;
        var tag       = settings.link && settings.link.url ? 'a' : 'button';
        var sizeClass = settings.size ? 'elementor-size-' + settings.size : '';
        // ⛔ hover_animation excluded — uncomment if control is re-enabled:
        // var animClass = settings.hover_animation ? 'elementor-animation-' + settings.hover_animation : '';
        #>
        <div class="elementor-button-wrapper">
            <{{{ tag }}} class="elementor-button {{{ sizeClass }}}">
                <span class="elementor-button-content-wrapper">
                    <# if ( hasIcon && 'left' === settings.icon_align ) { #>
                        <span class="elementor-button-icon elementor-align-icon-left">{{{ iconHTML.value }}}</span>
                    <# } #>
                    <# if ( settings.text ) { #>
                        <span class="elementor-button-text">{{ settings.text }}</span>
                    <# } #>
                    <# if ( hasIcon && 'right' === settings.icon_align ) { #>
                        <span class="elementor-button-icon elementor-align-icon-right">{{{ iconHTML.value }}}</span>
                    <# } #>
                </span>
            </{{{ tag }}}>
        </div>
        <?php
    }
    ```
    
    > **Remove checklist:**
    > - `button_type` → remove if no semantic type variants needed
    > - `size` → remove if button size is controlled purely via `text_padding`
    > - `selected_icon` + `icon_align` + `icon_indent` → remove if no icon support needed
    > - `button_id` → remove if button does not need JS/anchor targeting
    > - `hover_animation` → **excluded by default** (commented out)
    > - `button_hover_border_color` → remove if no border is used
    > - `text_shadow` → remove for simple flat-design buttons
    
    ---
    
  • widget-container.md 19.2 KB
    # Widget Boilerplate — Container
    
    > **When to use this file:** Load this boilerplate whenever building a widget that acts as a
    > layout wrapper — a section, card shell, hero block, or any element that wraps other content.
    >
    > Mirrors Elementor's native Container (Flexbox) panel exactly in control structure and order.
    > Use these controls inside `register_controls()` of your `Widget_Base` subclass.
    
    ---
    
    ### Container Widget Boilerplate
    
    Mirrors Elementor's native Container (Flexbox) panel. Use whenever a widget acts as a
    layout wrapper — a section, card shell, hero block, or any element that contains other
    widgets or content groups.
    
    > ⚠️ This boilerplate targets Elementor's Container element structure. When building a
    > custom widget that internally renders a container-like wrapper, use these controls inside
    > `register_controls()` of your `Widget_Base` subclass to match the UX pattern users already
    > know from native containers.
    
    ```php
    protected function register_controls(): void {
    
        // =========================================================
        // TAB: LAYOUT
        // =========================================================
    
        // ── LAYOUT ────────────────────────────────────────────────
        // ✅ TAB_LAYOUT is NOT available in Widget_Base — it exists only on the native Container/Section
        // element. Custom widgets only support TAB_CONTENT, TAB_STYLE, TAB_ADVANCED.
        // Use TAB_CONTENT for layout controls in custom widgets.
        $this->start_controls_section( 'section_layout', [
            'label' => esc_html__( 'Container', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $this->add_control( 'container_layout', [
            'label'   => esc_html__( 'Container Layout', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SELECT,
            'default' => 'flexbox',
            'options' => [
                'flexbox' => esc_html__( 'Flexbox', 'myplugin' ),
                // 'grid' => esc_html__( 'Grid', 'myplugin' ), // Elementor Pro 3.16+ only
            ],
        ] );
    
        $this->add_control( 'content_width', [
            'label'   => esc_html__( 'Content Width', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SELECT,
            'default' => 'full',
            'options' => [
                'full'  => esc_html__( 'Full Width', 'myplugin' ),
                'boxed' => esc_html__( 'Boxed',      'myplugin' ),
            ],
            'selectors_dictionary' => [
                'full'  => '100%',
                'boxed' => 'var(--container-max-width, 1140px)',
            ],
            'selectors' => [
                '{{WRAPPER}} .myplugin-container__inner' => 'max-width: {{VALUE}};',
            ],
        ] );
    
        $this->add_responsive_control( 'width', [
            'label'      => esc_html__( 'Width', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::SLIDER,
            'default'    => [ 'size' => 90, 'unit' => '%' ],
            'size_units' => [ 'px', '%', 'vw' ],
            'range'      => [
                'px' => [ 'min' => 0, 'max' => 2000 ],
                '%'  => [ 'min' => 0, 'max' => 100  ],
                'vw' => [ 'min' => 0, 'max' => 100  ],
            ],
            'selectors'  => [
                '{{WRAPPER}} .myplugin-container' => 'width: {{SIZE}}{{UNIT}};',
            ],
        ] );
    
        $this->add_responsive_control( 'min_height', [
            'label'       => esc_html__( 'Min Height', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::SLIDER,
            'default'     => [ 'size' => 100, 'unit' => 'vh' ],
            'size_units'  => [ 'px', 'vh', 'vw', 'em', 'rem' ],
            'range'       => [
                'px' => [ 'min' => 0, 'max' => 1500 ],
                'vh' => [ 'min' => 0, 'max' => 100  ],
                'vw' => [ 'min' => 0, 'max' => 100  ],
            ],
            'selectors'   => [
                '{{WRAPPER}} .myplugin-container' => 'min-height: {{SIZE}}{{UNIT}};',
            ],
            'description' => esc_html__( 'To achieve full height Container use 100vh.', 'myplugin' ),
        ] );
    
        $this->add_control( 'items_heading', [
            'label'     => esc_html__( 'Items', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::HEADING,
            'separator' => 'before',
        ] );
    
        $this->add_responsive_control( 'flex_direction', [
            'label'     => esc_html__( 'Direction', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::CHOOSE,
            'default'   => 'row',
            'options'   => [
                'row'            => [ 'title' => esc_html__( 'Row',            'myplugin' ), 'icon' => 'eicon-arrow-right' ],
                'column'         => [ 'title' => esc_html__( 'Column',         'myplugin' ), 'icon' => 'eicon-arrow-down'  ],
                'row-reverse'    => [ 'title' => esc_html__( 'Row Reverse',    'myplugin' ), 'icon' => 'eicon-arrow-left'  ],
                'column-reverse' => [ 'title' => esc_html__( 'Column Reverse', 'myplugin' ), 'icon' => 'eicon-arrow-up'    ],
            ],
            'selectors' => [
                '{{WRAPPER}} .myplugin-container' => 'flex-direction: {{VALUE}};',
            ],
        ] );
    
        $this->add_responsive_control( 'justify_content', [
            'label'     => esc_html__( 'Justify Content', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::CHOOSE,
            'default'   => 'flex-start',
            'options'   => [
                'flex-start'    => [ 'title' => esc_html__( 'Start',         'myplugin' ), 'icon' => 'eicon-flex eicon-justify-start-h'         ],
                'center'        => [ 'title' => esc_html__( 'Center',        'myplugin' ), 'icon' => 'eicon-flex eicon-justify-center-h'        ],
                'flex-end'      => [ 'title' => esc_html__( 'End',           'myplugin' ), 'icon' => 'eicon-flex eicon-justify-end-h'           ],
                'space-between' => [ 'title' => esc_html__( 'Space Between', 'myplugin' ), 'icon' => 'eicon-flex eicon-justify-space-between-h' ],
                'space-around'  => [ 'title' => esc_html__( 'Space Around',  'myplugin' ), 'icon' => 'eicon-flex eicon-justify-space-around-h'  ],
                'space-evenly'  => [ 'title' => esc_html__( 'Space Evenly',  'myplugin' ), 'icon' => 'eicon-flex eicon-justify-space-evenly-h'  ],
            ],
            'selectors' => [
                '{{WRAPPER}} .myplugin-container' => 'justify-content: {{VALUE}};',
            ],
        ] );
    
        $this->add_responsive_control( 'align_items', [
            'label'     => esc_html__( 'Align Items', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::CHOOSE,
            'default'   => 'flex-start',
            'options'   => [
                'flex-start' => [ 'title' => esc_html__( 'Start',   'myplugin' ), 'icon' => 'eicon-flex eicon-align-start-v'   ],
                'center'     => [ 'title' => esc_html__( 'Center',  'myplugin' ), 'icon' => 'eicon-flex eicon-align-center-v'  ],
                'flex-end'   => [ 'title' => esc_html__( 'End',     'myplugin' ), 'icon' => 'eicon-flex eicon-align-end-v'     ],
                'stretch'    => [ 'title' => esc_html__( 'Stretch', 'myplugin' ), 'icon' => 'eicon-flex eicon-align-stretch-v' ],
            ],
            'selectors' => [
                '{{WRAPPER}} .myplugin-container' => 'align-items: {{VALUE}};',
            ],
        ] );
    
        // ✅ Two separate gap controls — universally safe across all Elementor versions.
        // Controls_Manager::GAPS exists since 3.7 but its ROW.SIZE/COLUMN.SIZE selector tokens
        // are not documented in official Elementor developer docs. Use separate sliders instead.
        $this->add_responsive_control( 'column_gap', [
            'label'      => esc_html__( 'Column Gap', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::SLIDER,
            'default'    => [ 'size' => 20, 'unit' => 'px' ],
            'size_units' => [ 'px', '%', 'em', 'rem', 'vw' ],
            'range'      => [
                'px' => [ 'min' => 0, 'max' => 200 ],
                '%'  => [ 'min' => 0, 'max' => 100  ],
            ],
            'selectors'  => [
                '{{WRAPPER}} .myplugin-container' => 'column-gap: {{SIZE}}{{UNIT}};',
            ],
        ] );
    
        $this->add_responsive_control( 'row_gap', [
            'label'      => esc_html__( 'Row Gap', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::SLIDER,
            'default'    => [ 'size' => 20, 'unit' => 'px' ],
            'size_units' => [ 'px', '%', 'em', 'rem', 'vw' ],
            'range'      => [
                'px' => [ 'min' => 0, 'max' => 200 ],
                '%'  => [ 'min' => 0, 'max' => 100  ],
            ],
            'selectors'  => [
                '{{WRAPPER}} .myplugin-container' => 'row-gap: {{SIZE}}{{UNIT}};',
            ],
        ] );
    
        $this->add_responsive_control( 'flex_wrap', [
            'label'       => esc_html__( 'Wrap', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::CHOOSE,
            'default'     => 'nowrap',
            'options'     => [
                'nowrap' => [ 'title' => esc_html__( 'No Wrap', 'myplugin' ), 'icon' => 'eicon-flex eicon-nowrap' ],
                'wrap'   => [ 'title' => esc_html__( 'Wrap',    'myplugin' ), 'icon' => 'eicon-flex eicon-wrap'   ],
            ],
            'selectors'   => [
                '{{WRAPPER}} .myplugin-container' => 'flex-wrap: {{VALUE}};',
            ],
            'description' => esc_html__( 'Items within the container can stay in a single line (No wrap), or break into multiple lines (Wrap).', 'myplugin' ),
        ] );
    
        $this->end_controls_section();
    
        $this->start_controls_section( 'section_additional', [
            'label' => esc_html__( 'Additional Options', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
        // Add widget-specific layout extras here (overflow, z-index, etc.)
        $this->end_controls_section();
    
        // =========================================================
        // TAB: STYLE
        // =========================================================
    
        $this->start_controls_section( 'section_background', [
            'label' => esc_html__( 'Background', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->start_controls_tabs( 'tabs_background' );
    
            $this->start_controls_tab( 'tab_background_normal', [
                'label' => esc_html__( 'Normal', 'myplugin' ),
            ] );
    
                $this->add_group_control(
                    \Elementor\Group_Control_Background::get_type(),
                    [
                        'name'     => 'background',
                        // ✅ 'classic' and 'gradient' only — per official Elementor docs:
                        // "Video and slideshow types are supported only at section/container
                        // level, not widget level."
                        // Source: developers.elementor.com/docs/editor-controls/group-control-background/
                        'types'    => [ 'classic', 'gradient' ],
                        'selector' => '{{WRAPPER}} .myplugin-container',
                    ]
                );
    
            $this->end_controls_tab();
    
            $this->start_controls_tab( 'tab_background_hover', [
                'label' => esc_html__( 'Hover', 'myplugin' ),
            ] );
    
                $this->add_group_control(
                    \Elementor\Group_Control_Background::get_type(),
                    [
                        'name'     => 'background_hover',
                        'types'    => [ 'classic', 'gradient' ],
                        'selector' => '{{WRAPPER}} .myplugin-container:hover',
                    ]
                );
    
            $this->end_controls_tab();
    
        $this->end_controls_tabs();
    
        // ✅ Scrolling Effects & Mouse Effects — Elementor Pro only.
        // These controls register fine on free Elementor but have no effect without Pro.
        // Remove entirely if plugin targets free Elementor only.
        $this->add_control( 'scrolling_effects', [
            'label'       => esc_html__( 'Scrolling Effects', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::SWITCHER,
            'default'     => '',
            'separator'   => 'before',
            // ⚠️ PRO ONLY
            'description' => esc_html__( 'Requires Elementor Pro.', 'myplugin' ),
        ] );
    
        $this->add_control( 'mouse_effects', [
            'label'       => esc_html__( 'Mouse Effects', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::SWITCHER,
            'default'     => '',
            // ⚠️ PRO ONLY
            'description' => esc_html__( 'Requires Elementor Pro.', 'myplugin' ),
        ] );
    
        $this->end_controls_section();
    
        $this->start_controls_section( 'section_background_overlay', [
            'label' => esc_html__( 'Background Overlay', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->start_controls_tabs( 'tabs_background_overlay' );
    
            $this->start_controls_tab( 'tab_background_overlay_normal', [
                'label' => esc_html__( 'Normal', 'myplugin' ),
            ] );
    
                $this->add_group_control(
                    \Elementor\Group_Control_Background::get_type(),
                    [
                        'name'     => 'background_overlay',
                        'types'    => [ 'classic', 'gradient' ],
                        'selector' => '{{WRAPPER}} .myplugin-container__overlay',
                    ]
                );
    
            $this->end_controls_tab();
    
            $this->start_controls_tab( 'tab_background_overlay_hover', [
                'label' => esc_html__( 'Hover', 'myplugin' ),
            ] );
    
                $this->add_group_control(
                    \Elementor\Group_Control_Background::get_type(),
                    [
                        'name'     => 'background_overlay_hover',
                        'types'    => [ 'classic', 'gradient' ],
                        'selector' => '{{WRAPPER}} .myplugin-container:hover .myplugin-container__overlay',
                    ]
                );
    
            $this->end_controls_tab();
    
        $this->end_controls_tabs();
    
        $this->end_controls_section();
    
        $this->start_controls_section( 'section_border', [
            'label' => esc_html__( 'Border', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->start_controls_tabs( 'tabs_border' );
    
            $this->start_controls_tab( 'tab_border_normal', [
                'label' => esc_html__( 'Normal', 'myplugin' ),
            ] );
    
                $this->add_group_control(
                    \Elementor\Group_Control_Border::get_type(),
                    [
                        'name'     => 'border',
                        'selector' => '{{WRAPPER}} .myplugin-container',
                    ]
                );
    
                // ✅ Default 16px all sides matches Elementor's native container default
                $this->add_responsive_control( 'border_radius', [
                    'label'      => esc_html__( 'Border Radius', 'myplugin' ),
                    'type'       => \Elementor\Controls_Manager::DIMENSIONS,
                    'size_units' => [ 'px', '%', 'em', 'rem' ],
                    'default'    => [
                        'top'      => '16',
                        'right'    => '16',
                        'bottom'   => '16',
                        'left'     => '16',
                        'unit'     => 'px',
                        'isLinked' => true,
                    ],
                    'selectors'  => [
                        '{{WRAPPER}} .myplugin-container' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};',
                    ],
                ] );
    
                $this->add_group_control(
                    \Elementor\Group_Control_Box_Shadow::get_type(),
                    [
                        'name'     => 'box_shadow',
                        'selector' => '{{WRAPPER}} .myplugin-container',
                    ]
                );
    
            $this->end_controls_tab();
    
            $this->start_controls_tab( 'tab_border_hover', [
                'label' => esc_html__( 'Hover', 'myplugin' ),
            ] );
    
                $this->add_group_control(
                    \Elementor\Group_Control_Border::get_type(),
                    [
                        'name'     => 'border_hover',
                        'selector' => '{{WRAPPER}} .myplugin-container:hover',
                    ]
                );
    
                $this->add_responsive_control( 'border_radius_hover', [
                    'label'      => esc_html__( 'Border Radius', 'myplugin' ),
                    'type'       => \Elementor\Controls_Manager::DIMENSIONS,
                    'size_units' => [ 'px', '%', 'em', 'rem' ],
                    'selectors'  => [
                        '{{WRAPPER}} .myplugin-container:hover' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};',
                    ],
                ] );
    
                $this->add_group_control(
                    \Elementor\Group_Control_Box_Shadow::get_type(),
                    [
                        'name'     => 'box_shadow_hover',
                        'selector' => '{{WRAPPER}} .myplugin-container:hover',
                    ]
                );
    
            $this->end_controls_tab();
    
        $this->end_controls_tabs();
    
        $this->end_controls_section();
    
        // Shape Divider has no native Controls_Manager API. Implement via CSS or SVG in render().
        $this->start_controls_section( 'section_shape_divider', [
            'label' => esc_html__( 'Shape Divider', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_control( 'shape_divider_notice', [
            'type'            => \Elementor\Controls_Manager::RAW_HTML,
            'raw'             => esc_html__( 'Shape dividers are implemented via CSS or inline SVG in render(). Add your shape controls here if needed.', 'myplugin' ),
            'content_classes' => 'elementor-descriptor',
        ] );
    
        $this->end_controls_section();
    }
    ```
    
    **Matching render() skeleton:**
    
    ```php
    
    // ✅ REQUIRED on every widget — removes the redundant inner wrapper div.
    // Return true ONLY if your render() output physically requires the inner wrapper.
    // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ Return false to enable output caching for static widgets (same HTML for all users).
    // Return true if output varies per user, session, time, or random values.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
    
        $this->add_render_attribute( 'container', 'class', 'myplugin-container' );
        ?>
        <div <?php $this->print_render_attribute_string( 'container' ); ?>>
            <div class="myplugin-container__overlay"></div>
            <div class="myplugin-container__inner">
                <?php // Render child content here ?>
            </div>
        </div>
        <?php
    }
    ```
    
    > **Remove checklist:**
    > - `container_layout` → remove if widget only ever uses Flexbox
    > - `content_width` + inner wrapper div → remove if container is always full width
    > - `min_height` → remove for widgets that size naturally to their content
    > - `flex_wrap` → remove if items must always stay single line
    > - `scrolling_effects` + `mouse_effects` → remove if Elementor Pro is not required
    > - `section_background_overlay` + overlay div → remove if no overlay needed
    > - `section_shape_divider` → remove if no shape divider needed
    > - Border Radius default 16px → adjust or clear for sharp-corner designs
    
    ---
    
    
    **content_template() skeleton:**
    
    ```php
    protected function content_template(): void {
        ?>
        <div class="myplugin-container">
            <div class="myplugin-container__overlay"></div>
            <div class="myplugin-container__inner"></div>
        </div>
        <?php
    }
    ```
    
  • widget-counter.md 9.8 KB
    # Widget Boilerplate — Counter
    
    > **When to use this file:** Load whenever building a widget that animates a number counting up.
    > Verified against `elementor/includes/widgets/counter.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        // ── CONTENT ───────────────────────────────────────────────
        $this->start_controls_section( 'section_counter', [
            'label' => esc_html__( 'Counter', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $this->add_control( 'starting_number', [
            'label'   => esc_html__( 'Starting Number', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::NUMBER,
            'default' => 0,
            'dynamic' => [ 'active' => true ],
        ] );
    
        $this->add_control( 'ending_number', [
            'label'   => esc_html__( 'Ending Number', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::NUMBER,
            'default' => 100,
            'dynamic' => [ 'active' => true ],
        ] );
    
        $this->add_control( 'prefix', [
            'label'       => esc_html__( 'Number Prefix', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::TEXT,
            'default'     => '',
            'placeholder' => esc_html__( '$', 'myplugin' ),
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $this->add_control( 'suffix', [
            'label'       => esc_html__( 'Number Suffix', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::TEXT,
            'default'     => '',
            'placeholder' => esc_html__( 'Plus', 'myplugin' ),
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $this->add_control( 'duration', [
            'label'   => esc_html__( 'Animation Duration', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::NUMBER,
            'default' => 2000,
            'min'     => 100,
            'step'    => 100,
        ] );
    
        $this->add_control( 'thousand_separator', [
            'label'   => esc_html__( 'Thousand Separator', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SWITCHER,
            'default' => 'yes',
        ] );
    
        $this->add_control( 'thousand_separator_char', [
            'label'     => esc_html__( 'Separator', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SELECT,
            'condition' => [ 'thousand_separator' => 'yes' ],
            'options'   => [
                ''  => esc_html__( 'Default', 'myplugin' ),
                '.' => esc_html__( 'Dot',     'myplugin' ),
                ' ' => esc_html__( 'Space',   'myplugin' ),
            ],
        ] );
    
        $this->add_control( 'title', [
            'label'       => esc_html__( 'Title', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::TEXT,
            'label_block' => true,
            'default'     => esc_html__( 'Cool Number', 'myplugin' ),
            'placeholder' => esc_html__( 'Enter your counter title', 'myplugin' ),
            'separator'   => 'before',
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE ─────────────────────────────────────────────────
        $this->start_controls_section( 'section_number', [
            'label' => esc_html__( 'Number', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_control( 'number_color', [
            'label'     => esc_html__( 'Text Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'global'    => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_PRIMARY ],
            'selectors' => [ '{{WRAPPER}} .myplugin-counter-number-wrapper' => 'color: {{VALUE}};' ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Typography::get_type(),
            [
                'name'     => 'typography',
                'global'   => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_PRIMARY ],
                'selector' => '{{WRAPPER}} .myplugin-counter-number-wrapper',
            ]
        );
    
        $this->end_controls_section();
    
        $this->start_controls_section( 'section_title', [
            'label' => esc_html__( 'Title', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_control( 'title_color', [
            'label'     => esc_html__( 'Text Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'global'    => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_SECONDARY ],
            'selectors' => [ '{{WRAPPER}} .myplugin-counter-title' => 'color: {{VALUE}};' ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Typography::get_type(),
            [
                'name'     => 'title_typography',
                'global'   => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_SECONDARY ],
                'selector' => '{{WRAPPER}} .myplugin-counter-title',
            ]
        );
    
        $this->end_controls_section();
    }
    ```
    
    **render() skeleton:**
    
    ```php
    
    // ✅ REQUIRED on every widget — removes the redundant inner wrapper div.
    // Return true ONLY if your render() output physically requires the inner wrapper.
    // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ Return false to enable output caching for static widgets (same HTML for all users).
    // Return true if output varies per user, session, time, or random values.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
        $this->add_render_attribute( 'counter', [
            'class'                       => 'myplugin-counter-number',
            'data-duration'               => $settings['duration'],
            'data-to-value'               => $settings['ending_number'],
            'data-from-value'             => $settings['starting_number'],
            'data-delimiter'              => $settings['thousand_separator'] ? ( $settings['thousand_separator_char'] ?: ',' ) : '',
        ] );
        ?>
        <div class="myplugin-counter">
            <div class="myplugin-counter-number-wrapper">
                <span class="myplugin-counter-number-prefix"><?php echo esc_html( $settings['prefix'] ); ?></span>
                <span <?php $this->print_render_attribute_string( 'counter' ); ?>><?php echo esc_html( $settings['starting_number'] ); ?></span>
                <span class="myplugin-counter-number-suffix"><?php echo esc_html( $settings['suffix'] ); ?></span>
            </div>
            <?php if ( ! empty( $settings['title'] ) ) : ?>
                <p class="myplugin-counter-title"><?php echo esc_html( $settings['title'] ); ?></p>
            <?php endif; ?>
        </div>
        <?php
    }
    ```
    
    > ⚠️ **Required: Counter JS animation.** The `data-duration`, `data-to-value`, and
    > `data-from-value` attributes output by `render()` do nothing without a JS driver.
    > Use an `IntersectionObserver` to start the animation when the widget scrolls into view:
    > ```js
    > ( () => {
    >   'use strict';
    >   const animateCounter = ( el ) => {
    >     const from     = +el.dataset.fromValue || 0;
    >     const to       = +el.dataset.toValue   || 0;
    >     const duration = +el.dataset.duration  || 2000;
    >     const delim    = el.dataset.delimiter  || '';
    >     const start    = performance.now();
    >     const tick = ( now ) => {
    >       const elapsed  = Math.min( now - start, duration );
    >       const progress = elapsed / duration;
    >       const current  = Math.round( from + ( to - from ) * progress );
    >       el.textContent = delim
    >         ? current.toLocaleString()
    >         : String( current );
    >       if ( elapsed < duration ) requestAnimationFrame( tick );
    >     };
    >     // ✅ Respect prefers-reduced-motion — skip animation, jump to final value
    >     if ( window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches ) {
    >       el.textContent = String( to );
    >       return;
    >     }
    >     requestAnimationFrame( tick );
    >   };
    >
    >   window.addEventListener( 'elementor/frontend/init', () => {
    >     window.elementorFrontend.hooks.addAction(
    >       'frontend/element_ready/myplugin-widget.default',
    >       ( $scope ) => {
    >         if ( ! $scope || ! $scope[0] ) return;
    >         const counter = $scope[0].querySelector( '.myplugin-counter-number' );
    >         if ( ! counter ) return;
    >         const observer = new IntersectionObserver( ( entries ) => {
    >           entries.forEach( entry => {
    >             if ( entry.isIntersecting ) {
    >               animateCounter( counter );
    >               observer.unobserve( entry.target );
    >             }
    >           } );
    >         }, { threshold: 0.5 } );
    >         observer.observe( counter );
    >       }
    >     );
    >   } );
    > } )();
    > ```
    
    > **Remove checklist:**
    > - `prefix` + `suffix` → remove if counter shows a plain number only
    > - `thousand_separator` → remove if numbers are always small
    > - `title` → remove if counter has no label
    
    
    **content_template() skeleton:**
    
    ```php
    protected function content_template(): void {
        ?>
        <div class="myplugin-counter">
            <div class="myplugin-counter-number-wrapper">
                <span class="myplugin-counter-number-prefix">{{ settings.prefix }}</span>
                <span class="myplugin-counter-number"
                    data-duration="{{ settings.duration }}"
                    data-to-value="{{ settings.ending_number }}"
                    data-from-value="{{ settings.starting_number }}">{{ settings.starting_number }}</span>
                <span class="myplugin-counter-number-suffix">{{ settings.suffix }}</span>
            </div>
            <# if ( settings.title ) { #>
                <p class="myplugin-counter-title">{{ settings.title }}</p>
            <# } #>
        </div>
        <?php
    }
    ```
    
  • widget-divider.md 9.3 KB
    # Widget Boilerplate — Divider
    
    > **When to use this file:** Load whenever building a widget that separates content sections with a styled line.
    > Verified against `elementor/includes/widgets/divider.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        // ── CONTENT ───────────────────────────────────────────────
        $this->start_controls_section( 'section_divider', [
            'label' => esc_html__( 'Divider', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $this->add_control( 'style', [
            'label'        => esc_html__( 'Style', 'myplugin' ),
            'type'         => \Elementor\Controls_Manager::SELECT,
            'default'      => 'solid',
            'options'      => [
                'solid'  => esc_html__( 'Solid',  'myplugin' ),
                'double' => esc_html__( 'Double', 'myplugin' ),
                'dotted' => esc_html__( 'Dotted', 'myplugin' ),
                'dashed' => esc_html__( 'Dashed', 'myplugin' ),
            ],
            'selectors'    => [
                '{{WRAPPER}} .myplugin-divider-separator' => 'border-top-style: {{VALUE}};',
            ],
            'prefix_class' => 'myplugin-divider-style-',
        ] );
    
        $this->add_responsive_control( 'width', [
            'label'      => esc_html__( 'Width', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::SLIDER,
            'size_units' => [ '%', 'px', 'vw' ],
            'range'      => [
                '%'  => [ 'min' => 1,  'max' => 100  ],
                'px' => [ 'min' => 1,  'max' => 1000 ],
                'vw' => [ 'min' => 1,  'max' => 100  ],
            ],
            'default'    => [ 'unit' => '%', 'size' => 100 ],
            'selectors'  => [
                '{{WRAPPER}} .myplugin-divider-separator' => 'width: {{SIZE}}{{UNIT}};',
            ],
        ] );
    
        $this->add_responsive_control( 'align', [
            'label'     => esc_html__( 'Alignment', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::CHOOSE,
            'options'   => [
                'left'   => [ 'title' => esc_html__( 'Left',   'myplugin' ), 'icon' => 'eicon-text-align-left'   ],
                'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ],
                'right'  => [ 'title' => esc_html__( 'Right',  'myplugin' ), 'icon' => 'eicon-text-align-right'  ],
            ],
            'selectors' => [
                '{{WRAPPER}} .myplugin-divider' => 'text-align: {{VALUE}};',
                // ✅ margin trick centres the separator line regardless of width
                '{{WRAPPER}} .myplugin-divider-separator' => 'margin: 0 auto; margin-{{VALUE}}: 0;',
            ],
        ] );
    
        // Add Element — none / text / icon
        $this->add_control( 'look', [
            'label'       => esc_html__( 'Add Element', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::CHOOSE,
            'options'     => [
                'line'      => [ 'title' => esc_html__( 'None', 'myplugin' ), 'icon' => 'eicon-minus' ],
                'line_text' => [ 'title' => esc_html__( 'Text', 'myplugin' ), 'icon' => 'eicon-t-letter' ],
                'line_icon' => [ 'title' => esc_html__( 'Icon', 'myplugin' ), 'icon' => 'eicon-star' ],
            ],
            'default'     => 'line',
            'prefix_class'=> 'myplugin-divider-',
            'label_block' => false,
        ] );
    
        $this->add_control( 'text', [
            'label'     => esc_html__( 'Text', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::TEXT,
            'default'   => esc_html__( 'Divider', 'myplugin' ),
            'condition' => [ 'look' => 'line_text' ],
            'dynamic'   => [ 'active' => true ],
        ] );
    
        $this->add_control( 'selected_icon', [
            'label'     => esc_html__( 'Icon', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::ICONS,
            'default'   => [ 'value' => 'fas fa-star', 'library' => 'fa-solid' ],
            'condition' => [ 'look' => 'line_icon' ],
        ] );
    
        // Text/icon position relative to the line
        $this->add_control( 'text_align', [
            'label'     => esc_html__( 'Position', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::CHOOSE,
            'options'   => [
                'left'   => [ 'title' => esc_html__( 'Left',   'myplugin' ), 'icon' => 'eicon-h-align-left'   ],
                'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-h-align-center' ],
                'right'  => [ 'title' => esc_html__( 'Right',  'myplugin' ), 'icon' => 'eicon-h-align-right'  ],
            ],
            'default'   => 'center',
            'condition' => [ 'look!' => 'line' ],
        ] );
    
        $this->add_control( 'middle_width', [
            'label'     => esc_html__( 'Spacing', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SLIDER,
            'range'     => [ 'px' => [ 'min' => 1, 'max' => 50 ] ],
            'default'   => [ 'size' => 10, 'unit' => 'px' ],
            'condition' => [ 'look!' => 'line' ],
            'selectors' => [
                '{{WRAPPER}} .myplugin-divider__text' => 'padding: 0 {{SIZE}}{{UNIT}};',
                '{{WRAPPER}} .myplugin-divider__icon' => 'padding: 0 {{SIZE}}{{UNIT}};',
            ],
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE ─────────────────────────────────────────────────
        $this->start_controls_section( 'section_divider_style', [
            'label' => esc_html__( 'Divider', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_control( 'color', [
            'label'     => esc_html__( 'Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'global'    => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_SECONDARY ],
            'selectors' => [
                '{{WRAPPER}} .myplugin-divider-separator' => 'border-top-color: {{VALUE}};',
                '{{WRAPPER}} .myplugin-divider__text'     => 'color: {{VALUE}};',
                '{{WRAPPER}} .myplugin-divider__icon i'   => 'color: {{VALUE}};',
                '{{WRAPPER}} .myplugin-divider__icon svg' => 'fill: {{VALUE}};',
            ],
        ] );
    
        $this->add_control( 'weight', [
            'label'     => esc_html__( 'Weight', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SLIDER,
            'range'     => [ 'px' => [ 'min' => 1, 'max' => 10 ] ],
            'default'   => [ 'size' => 1, 'unit' => 'px' ],
            'selectors' => [
                '{{WRAPPER}} .myplugin-divider-separator' => 'border-top-width: {{SIZE}}{{UNIT}};',
            ],
        ] );
    
        $this->add_responsive_control( 'gap', [
            'label'      => esc_html__( 'Gap', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::SLIDER,
            'size_units' => [ 'px', 'em', 'rem' ],
            'range'      => [ 'px' => [ 'min' => 0, 'max' => 100 ] ],
            'default'    => [ 'size' => 15, 'unit' => 'px' ],
            'selectors'  => [
                '{{WRAPPER}} .myplugin-divider' => 'padding-top: {{SIZE}}{{UNIT}}; padding-bottom: {{SIZE}}{{UNIT}};',
            ],
        ] );
    
        $this->end_controls_section();
    }
    ```
    
    **render() skeleton:**
    
    ```php
    
    // ✅ REQUIRED on every widget — removes the redundant inner wrapper div.
    // Return true ONLY if your render() output physically requires the inner wrapper.
    // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ Return false to enable output caching for static widgets (same HTML for all users).
    // Return true if output varies per user, session, time, or random values.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
        ?>
        <div class="myplugin-divider">
            <span class="myplugin-divider-separator">
                <?php if ( 'line_text' === $settings['look'] && ! empty( $settings['text'] ) ) : ?>
                    <span class="myplugin-divider__text"><?php echo esc_html( $settings['text'] ); ?></span>
                <?php elseif ( 'line_icon' === $settings['look'] && ! empty( $settings['selected_icon']['value'] ) ) : ?>
                    <span class="myplugin-divider__icon">
                        <?php \Elementor\Icons_Manager::render_icon( $settings['selected_icon'], [ 'aria-hidden' => 'true' ] ); ?>
                    </span>
                <?php endif; ?>
            </span>
        </div>
        <?php
    }
    ```
    
    > **Remove checklist:**
    > - `look` + `text` + `selected_icon` + `text_align` + `middle_width` → remove if divider is always a plain line
    
    
    **content_template() skeleton:**
    
    ```php
    protected function content_template(): void {
        ?>
        <div class="myplugin-divider">
            <span class="myplugin-divider-separator">
                <# if ( 'line_text' === settings.look && settings.text ) { #>
                    <span class="myplugin-divider__text">{{ settings.text }}</span>
                <# } else if ( 'line_icon' === settings.look && settings.selected_icon && settings.selected_icon.value ) {
                    var iconHTML = elementor.helpers.renderIcon( view, settings.selected_icon, { 'aria-hidden': true }, 'i', 'object' );
                #>
                    <span class="myplugin-divider__icon">{{{ iconHTML.value }}}</span>
                <# } #>
            </span>
        </div>
        <?php
    }
    ```
    
  • widget-elementor-template.md 11.2 KB
    # Widget Pattern — Render an Elementor Template
    
    > **When to use this file:** Load whenever building a widget that renders a saved
    > Elementor template (page, section, or reusable block) inside its `render()`.
    > This is the **default / recommended approach** when a widget needs a user-designed
    > inner layout. Use `widget-php-template.md` only when the markup is static PHP.
    >
    > **API source:** `\Elementor\Plugin::$instance->frontend->get_builder_content_for_display()`
    > Source: `elementor/includes/frontend.php` — Elementor GitHub main branch.
    > Note: `get_builder_content()` is the internal method; always call the public
    > `get_builder_content_for_display()` wrapper instead.
    
    ---
    
    ## How It Works
    
    The user picks a saved Elementor template (post type `elementor_library`) by ID
    via a SELECT control in your widget. Your `render()` calls
    `get_builder_content_for_display( $template_id, $with_css )` which:
    
    1. Verifies the post exists and is built with Elementor.
    2. Renders all Elementor elements in that template through the normal frontend pipeline.
    3. Returns the full rendered HTML string — echo it directly.
    
    > ⚠️ **`$with_css = true` vs `false`:**
    > Both values always produce CSS — the difference is WHERE it is output.
    > Source: `elementor/includes/frontend.php` (Elementor GitHub main branch).
    > - `false` (default) — CSS is enqueued to the page `<head>` via `wp_enqueue_style`.
    >   Correct for standard frontend use on any Elementor-built page.
    > - `true` — CSS is printed inline as a `<style>` block directly above the HTML in the
    >   returned string. Use for AJAX responses, shortcodes loaded after `wp_head`, or any
    >   context where `<head>` is unavailable or has already fired.
    >   Note: Elementor automatically forces `$with_css = true` on AJAX and Customizer requests.
    
    ---
    
    ## register_controls() skeleton
    
    ```php
    protected function register_controls(): void {
    
        // ── CONTENT ───────────────────────────────────────────────────────────────
        $this->start_controls_section( 'section_template', [
            'label' => esc_html__( 'Template', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        // ✅ SELECT2 control — searchable dropdown for large lists of templates.
        // Using plain SELECT is fine for small lists but SELECT2 is the correct
        // control for elementor_library which can grow to hundreds of entries.
        // Cast each option key to string — WP_Post->ID is int; SELECT2 compares as strings.
        $this->add_control( 'template_id', [
            'label'       => esc_html__( 'Choose Template', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::SELECT2,
            'options'     => $this->get_template_options(),
            'default'     => '',
            'label_block' => true,
            // ✅ is_dynamic_content() must return true when template_id is used,
            // because the rendered output varies per template and must not be cached.
            // See is_dynamic_content() override in the render skeleton below.
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE ─────────────────────────────────────────────────────────────────
        $this->start_controls_section( 'section_wrapper_style', [
            'label' => esc_html__( 'Wrapper', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_responsive_control( 'min_height', [
            'label'      => esc_html__( 'Min Height', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::SLIDER,
            'size_units' => [ 'px', 'vh', 'em', 'rem' ],
            'range'      => [ 'px' => [ 'min' => 0, 'max' => 1000 ] ],
            'selectors'  => [
                '{{WRAPPER}} .myplugin-template-wrapper' => 'min-height: {{SIZE}}{{UNIT}};',
            ],
        ] );
    
        $this->end_controls_section();
    }
    ```
    
    ---
    
    ## Required helper method
    
    ```php
    /**
     * Build SELECT options from published Elementor templates.
     *
     * Queries the elementor_library CPT directly — avoids loading the full
     * Template Library API which is Pro-only for certain source types.
     *
     * @return array<string, string> Option key = post ID (string), value = post title.
     */
    private function get_template_options(): array {
        // ✅ Empty string key '' matches the SELECT2 default value of ''.
        // Using '0' as the placeholder key would be treated as a valid post ID.
        $options = [ '' => esc_html__( '— Select Template —', 'myplugin' ) ];
    
        $templates = get_posts( [
            'post_type'      => 'elementor_library',
            'post_status'    => 'publish',
            'posts_per_page' => -1,
            'orderby'        => 'title',
            'order'          => 'ASC',
            // ✅ no_found_rows: true — skip COUNT(*) since we don't paginate here
            'no_found_rows'  => true,
        ] );
    
        foreach ( $templates as $template ) {
            $options[ (string) $template->ID ] = $template->post_title;
        }
    
        return $options;
    }
    ```
    
    ---
    
    ## has_widget_inner_wrapper() + is_dynamic_content() + render() skeleton
    
    ```php
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ MUST return true — rendered output depends entirely on which template
    // the user selects. Caching the output would freeze one template's HTML
    // permanently, even after the user changes the selection.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return true;
    }
    
    protected function render(): void {
        $settings    = $this->get_settings_for_display();
        $template_id = (int) ( $settings['template_id'] ?? 0 );
    
        if ( $template_id < 1 ) {
            // ✅ Show a placeholder in the editor so the user knows to pick a template
            if ( \Elementor\Plugin::$instance->editor
                 && \Elementor\Plugin::$instance->editor->is_edit_mode() ) {
                echo '<div style="padding:2rem;text-align:center;background:#f0f0f0;color:#888;">'
                     . esc_html__( 'Please select an Elementor template.', 'myplugin' )
                     . '</div>';
            }
            return;
        }
    
        // ✅ Guard: do NOT render if template_id equals the current post ID.
        // Elementor itself outputs this exact error string when recursion is detected.
        // Source: elementor/includes/frontend.php — get_builder_content_for_display()
        if ( $template_id === get_the_ID() ) {
            echo '<div class="elementor-alert elementor-alert-danger">'
                 . esc_html__( 'Invalid Data: The Template ID cannot be the same as the currently edited template. Please choose a different one.', 'elementor' )
                 . '</div>';
            return;
        }
    
        // ✅ Verify the post exists and is published before passing to Elementor.
        // get_builder_content_for_display() silently returns '' for missing posts.
        $template_post = get_post( $template_id );
        if ( ! $template_post || 'publish' !== $template_post->post_status ) {
            if ( \Elementor\Plugin::$instance->editor
                 && \Elementor\Plugin::$instance->editor->is_edit_mode() ) {
                echo '<div style="padding:1rem;background:#fff3cd;color:#856404;">'
                     . esc_html__( 'Selected template is not published or does not exist.', 'myplugin' )
                     . '</div>';
            }
            return;
        }
    
        echo '<div class="myplugin-template-wrapper">';
    
        // ✅ get_builder_content_for_display() is the public API.
        // $with_css = true — always inline the template CSS to ensure styles load
        // even when this widget is the only Elementor element on the page.
        // Source: elementor/includes/frontend.php (Elementor GitHub main branch)
        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
        echo \Elementor\Plugin::$instance->frontend->get_builder_content_for_display(
            $template_id,
            true  // $with_css
        );
    
        echo '</div>';
    }
    ```
    
    ---
    
    ## content_template() skeleton
    
    ```php
    protected function content_template(): void {
        ?>
        <#
        var templateId = parseInt( settings.template_id, 10 );
        #>
        <# if ( ! templateId ) { #>
            <div style="padding:2rem;text-align:center;background:#f0f0f0;color:#888;">
                <?php echo esc_html__( 'Please select an Elementor template.', 'myplugin' ); ?>
            </div>
        <# } else { #>
            <div class="myplugin-template-wrapper myplugin-template-placeholder"
                 style="padding:2rem;text-align:center;background:#e8f4fd;color:#0c5460;">
                <span class="eicon-elementor" style="font-size:2rem;display:block;margin-bottom:.5rem;"></span>
                <?php echo esc_html__( 'Elementor Template ID:', 'myplugin' ); ?>
                <strong>{{ templateId }}</strong>
                <br>
                <small><?php echo esc_html__( 'Renders on frontend only.', 'myplugin' ); ?></small>
            </div>
        <# } #>
        <?php
    }
    ```
    
    > **Why the placeholder?** `get_builder_content_for_display()` is a PHP method —
    > it cannot run inside the JS `content_template()`. The editor preview always shows
    > a placeholder. The actual rendered template is only visible on the frontend.
    
    ---
    
    ## Known limitations & gotchas
    
    > **Dynamic content in nested templates:**
    > If the selected template contains Dynamic Tags (Post Title, ACF fields, etc.),
    > `get_builder_content_for_display()` renders them in the context of the current
    > page's `$post` — it does **not** switch the global `$post` to the template post.
    > This is expected behaviour.
    > Source: github.com/elementor/elementor/issues/31600 · github.com/elementor/elementor/issues/13751
    
    If you need the template's Dynamic Tags to read a **specific** post (e.g. a CPT entry whose
    ID differs from the current page), set up that post context explicitly before the call:
    
    ```php
    // Make Dynamic Tags inside the template resolve against $target_id, not the current page.
    global $post;
    $original = $post;
    $post = get_post( $target_id );   // the post whose meta the template should read
    setup_postdata( $post );
    
    echo \Elementor\Plugin::$instance->frontend->get_builder_content_for_display( $template_id, true );
    
    wp_reset_postdata();
    $post = $original;   // belt-and-braces restore
    ```
    
    > **Recursive template guard:**
    > Never allow a template to embed itself. The `$template_id === get_the_ID()` guard
    > above mirrors Elementor's own internal check in `frontend.php`. Without it,
    > recursive rendering will produce a fatal error or infinite loop.
    
    > **Output caching:**
    > Because `is_dynamic_content()` returns `true`, Elementor will never cache this
    > widget's output. This is correct — the rendered HTML changes depending on the
    > template the user selects and its own dynamic content.
    
    > **Page CSS vs inline CSS:**
    > `$with_css = true` adds a `<style>` block inline. On high-traffic pages, consider
    > whether this causes duplicate style output if many instances exist. An alternative
    > is to enqueue the template's CSS file separately using
    > `\Elementor\Core\Files\CSS\Post` — but `$with_css = true` is the simplest safe default.
    
    ---
    
    > **Remove checklist:**
    > - `min_height` responsive control → remove for simple full-bleed embeds
    > - Editor placeholder styles → adjust colours to match project design system
    > - `get_template_options()` query → add `'meta_query'` to filter by template type if needed
    
  • widget-google-maps.md 5.3 KB
    # Widget Boilerplate — Google Maps
    
    > **When to use this file:** Load whenever building a widget that embeds a Google Maps iframe.
    > Verified against `elementor/includes/widgets/google-maps.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        $this->start_controls_section( 'section_map', [
            'label' => esc_html__( 'Map', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $this->add_control( 'address', [
            'label'       => esc_html__( 'Location', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::TEXT,
            'placeholder' => esc_html__( 'London Eye, London, UK', 'myplugin' ),
            'default'     => esc_html__( 'London Eye, London, UK', 'myplugin' ),
            'label_block' => true,
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $this->add_control( 'zoom', [
            'label'   => esc_html__( 'Zoom', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SLIDER,
            'default' => [ 'size' => 10 ],
            'range'   => [ 'px' => [ 'min' => 1, 'max' => 20 ] ],
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE ─────────────────────────────────────────────────
        $this->start_controls_section( 'section_map_style', [
            'label' => esc_html__( 'Map', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_responsive_control( 'height', [
            'label'     => esc_html__( 'Height', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SLIDER,
            'range'     => [ 'px' => [ 'min' => 40, 'max' => 1440 ] ],
            'default'   => [ 'size' => 300, 'unit' => 'px' ],
            'selectors' => [ '{{WRAPPER}} .myplugin-map' => 'height: {{SIZE}}{{UNIT}};' ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Css_Filter::get_type(),
            [ 'name' => 'map_filter', 'selector' => '{{WRAPPER}} .myplugin-map iframe' ]
        );
    
        $this->end_controls_section();
    }
    ```
    
    **render() skeleton:**
    
    ```php
    
    // ✅ REQUIRED on every widget — removes the redundant inner wrapper div.
    // Return true ONLY if your render() output physically requires the inner wrapper.
    // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ Return false to enable output caching for static widgets (same HTML for all users).
    // Return true if output varies per user, session, time, or random values.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
        if ( empty( $settings['address'] ) ) return;
    
        $zoom    = (int) ( $settings['zoom']['size'] ?? 10 );
        $address = rawurlencode( $settings['address'] );
    
        // ✅ TWO URL PATTERNS — choose based on whether a Google API key is configured.
        //
        // PATTERN A (no API key) — legacy Maps embed, no key required, still functional:
        //   https://maps.google.com/maps?q={address}&t=m&z={zoom}&output=embed&iwloc=near
        //   This is the same fallback Elementor's native widget uses when no API key is set.
        //   It is NOT deprecated for no-key usage — Google still serves it and Elementor relies
        //   on it. Source: elementor/includes/widgets/google-maps.php (Elementor 3.35+ / V3 Widget_Base API, current through 4.2)
        //
        // PATTERN B (with API key) — official Maps Embed API, recommended by Google:
        //   https://www.google.com/maps/embed/v1/place?key={API_KEY}&q={address}&zoom={zoom}
        //   Requires a Google Cloud project with Maps Embed API enabled (free, unlimited calls).
        //   Source: developers.google.com/maps/documentation/embed/get-started
        //
        // Use a plugin option or site setting to switch between patterns:
        $api_key = get_option( 'myplugin_google_maps_api_key', '' );
    
        if ( ! empty( $api_key ) ) {
            // Pattern B — Maps Embed API (preferred when key is available)
            $src = sprintf(
                'https://www.google.com/maps/embed/v1/place?key=%s&q=%s&zoom=%d',
                rawurlencode( sanitize_text_field( $api_key ) ),
                $address,
                $zoom
            );
        } else {
            // Pattern A — no-key fallback (still supported by Google, used by Elementor native)
            $src = sprintf(
                'https://maps.google.com/maps?q=%s&t=m&z=%d&output=embed&iwloc=near',
                $address,
                $zoom
            );
        }
        ?>
        <div class="myplugin-map">
            <iframe loading="lazy" src="<?php echo esc_url( $src ); ?>" title="<?php echo esc_attr( $settings['address'] ); ?>" aria-label="<?php echo esc_attr( $settings['address'] ); ?>"></iframe>
        </div>
        <?php
    }
    ```
    
    
    **content_template() skeleton:**
    
    ```php
    protected function content_template(): void {
        ?>
        <# if ( settings.address ) { #>
        <div class="myplugin-map">
            <div class="myplugin-map-placeholder" style="display:flex;align-items:center;justify-content:center;background:#eee;height:300px;">
                <i class="eicon-map-pin" aria-hidden="true" style="font-size:48px;color:#aaa;"></i>
            </div>
        </div>
        <# } #>
        <?php
    }
    ```
    
  • widget-heading.md 8.5 KB
    # Widget Boilerplate — Heading
    
    > **When to use this file:** Load whenever building a widget with a primary headline/title element.
    > Verified against `elementor/includes/widgets/heading.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        // ── CONTENT ───────────────────────────────────────────────
        $this->start_controls_section( 'section_title', [
            'label' => esc_html__( 'Title', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $this->add_control( 'title', [
            'label'       => esc_html__( 'Title', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::TEXTAREA,
            'dynamic'     => [ 'active' => true ],
            'placeholder' => esc_html__( 'Enter your title', 'myplugin' ),
            'default'     => esc_html__( 'Add Your Heading Text Here', 'myplugin' ),
        ] );
    
        $this->add_control( 'link', [
            'label'     => esc_html__( 'Link', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::URL,
            'dynamic'   => [ 'active' => true ],
            'separator' => 'before',
        ] );
    
        $this->add_control( 'size', [
            'label'   => esc_html__( 'Size', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SELECT,
            'default' => 'default',
            'options' => [
                'default' => esc_html__( 'Default', 'myplugin' ),
                'small'   => esc_html__( 'Small',   'myplugin' ),
                'medium'  => esc_html__( 'Medium',  'myplugin' ),
                'large'   => esc_html__( 'Large',   'myplugin' ),
                'xl'      => esc_html__( 'XL',      'myplugin' ),
                'xxl'     => esc_html__( 'XXL',     'myplugin' ),
            ],
        ] );
    
        // ✅ header_size = the HTML tag — h1–h6, div, span, p
        $this->add_control( 'header_size', [
            'label'   => esc_html__( 'HTML Tag', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SELECT,
            'options' => [
                'h1'   => 'H1',
                'h2'   => 'H2',
                'h3'   => 'H3',
                'h4'   => 'H4',
                'h5'   => 'H5',
                'h6'   => 'H6',
                'div'  => 'div',
                'span' => 'span',
                'p'    => 'p',
            ],
            'default' => 'h2',
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE ─────────────────────────────────────────────────
        $this->start_controls_section( 'section_title_style', [
            'label' => esc_html__( 'Title', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_responsive_control( 'align', [
            'label'     => esc_html__( 'Alignment', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::CHOOSE,
            'options'   => [
                'left'    => [ 'title' => esc_html__( 'Left',    'myplugin' ), 'icon' => 'eicon-text-align-left'    ],
                'center'  => [ 'title' => esc_html__( 'Center',  'myplugin' ), 'icon' => 'eicon-text-align-center'  ],
                'right'   => [ 'title' => esc_html__( 'Right',   'myplugin' ), 'icon' => 'eicon-text-align-right'   ],
                'justify' => [ 'title' => esc_html__( 'Justify', 'myplugin' ), 'icon' => 'eicon-text-align-justify' ],
            ],
            'selectors' => [ '{{WRAPPER}} .myplugin-heading' => 'text-align: {{VALUE}};' ],
        ] );
    
        $this->add_control( 'title_color', [
            'label'     => esc_html__( 'Text Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'global'    => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_PRIMARY ],
            'selectors' => [ '{{WRAPPER}} .myplugin-heading, {{WRAPPER}} .myplugin-heading a' => 'color: {{VALUE}};' ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Typography::get_type(),
            [
                'name'     => 'typography',
                'global'   => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_PRIMARY ],
                'selector' => '{{WRAPPER}} .myplugin-heading',
            ]
        );
    
        $this->add_group_control(
            \Elementor\Group_Control_Text_Shadow::get_type(),
            [
                'name'     => 'text_shadow',
                'selector' => '{{WRAPPER}} .myplugin-heading',
            ]
        );
    
        $this->add_control( 'blend_mode', [
            'label'     => esc_html__( 'Blend Mode', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SELECT,
            'options'   => [
                ''            => esc_html__( 'Normal',      'myplugin' ),
                'multiply'    => esc_html__( 'Multiply',    'myplugin' ),
                'screen'      => esc_html__( 'Screen',      'myplugin' ),
                'overlay'     => esc_html__( 'Overlay',     'myplugin' ),
                'darken'      => esc_html__( 'Darken',      'myplugin' ),
                'lighten'     => esc_html__( 'Lighten',     'myplugin' ),
                'color-dodge' => esc_html__( 'Color Dodge', 'myplugin' ),
                'saturation'  => esc_html__( 'Saturation',  'myplugin' ),
                'color'       => esc_html__( 'Color',       'myplugin' ),
                'difference'  => esc_html__( 'Difference',  'myplugin' ),
                'exclusion'   => esc_html__( 'Exclusion',   'myplugin' ),
                'hue'         => esc_html__( 'Hue',         'myplugin' ),
                'luminosity'  => esc_html__( 'Luminosity',  'myplugin' ),
            ],
            'selectors' => [ '{{WRAPPER}} .myplugin-heading' => 'mix-blend-mode: {{VALUE}};' ],
            'separator' => 'none',
        ] );
    
        $this->end_controls_section();
    }
    ```
    
    **render() skeleton:**
    
    ```php
    
    // ✅ REQUIRED on every widget — removes the redundant inner wrapper div.
    // Return true ONLY if your render() output physically requires the inner wrapper.
    // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ Return false to enable output caching for static widgets (same HTML for all users).
    // Return true if output varies per user, session, time, or random values.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
    
        if ( empty( $settings['title'] ) ) {
            return;
        }
    
        $this->add_render_attribute( 'title', 'class', 'myplugin-heading' );
    
        if ( ! empty( $settings['size'] ) && 'default' !== $settings['size'] ) {
            $this->add_render_attribute( 'title', 'class', 'elementor-size-' . $settings['size'] );
        }
    
        $title = $settings['title'];
    
        if ( ! empty( $settings['link']['url'] ) ) {
            $this->add_link_attributes( 'url', $settings['link'] );
            // ✅ wp_kses_post() on $title — TEXTAREA control returns raw user input.
            // get_settings_for_display() does not auto-escape TEXTAREA values.
            $title = sprintf( '<a %1$s>%2$s</a>', $this->get_render_attribute_string( 'url' ), wp_kses_post( $title ) );
        } else {
            $title = wp_kses_post( $title );
        }
    
        $tag = \Elementor\Utils::validate_html_tag( $settings['header_size'] );
        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- tag validated, title kses'd, attrs via add_render_attribute
        printf( '<%1$s %2$s>%3$s</%1$s>', $tag, $this->get_render_attribute_string( 'title' ), $title );
    }
    
    protected function content_template(): void {
        ?>
        <#
        // ✅ Escape user text with _.escape() before it reaches {{{ title }}} — the triple-brace is
        // required for the optional <a> wrapper (HTML), so the text/URL must be escaped HERE, or it
        // is an editor-context XSS that wp.org review rejects. See field-notes.md §2.
        var title = _.escape( settings.title );
        if ( settings.link && settings.link.url ) {
            title = '<a href="' + _.escape( settings.link.url ) + '">' + title + '</a>';
        }
        var tag  = elementor.helpers.validateHTMLTag( settings.header_size );
        var size = settings.size && 'default' !== settings.size ? ' elementor-size-' + settings.size : '';
        #>
        <{{{ tag }}} class="myplugin-heading{{{ size }}}">{{{ title }}}</{{{ tag }}}>
        <?php
    }
    ```
    
    > **Remove checklist:**
    > - `size` → remove if heading size is controlled purely via typography
    > - `link` → remove if heading is never linked
    > - `blend_mode` → remove for simple designs
    > - `text_shadow` → remove for flat designs
    
  • widget-html.md 2.4 KB
    # Widget Boilerplate — HTML
    
    > **When to use this file:** Load whenever building a widget that outputs raw custom HTML/JS/CSS code.
    > Verified against `elementor/includes/widgets/html.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        $this->start_controls_section( 'section_html', [
            'label' => esc_html__( 'HTML', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $this->add_control( 'html', [
            'label'       => esc_html__( 'HTML Code', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::CODE,
            'language'    => 'html',
            'rows'        => 20,
            'default'     => '',
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $this->end_controls_section();
    }
    ```
    
    **render() skeleton:**
    
    ```php
    
    // ✅ REQUIRED on every widget — removes the redundant inner wrapper div.
    // Return true ONLY if your render() output physically requires the inner wrapper.
    // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ Return false to enable output caching for static widgets (same HTML for all users).
    // Return true if output varies per user, session, time, or random values.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
    
        if ( empty( $settings['html'] ) ) {
            return;
        }
    
        // ✅ HTML widget outputs raw user HTML — this is intentional and matches
        // Elementor's native HTML widget behaviour. Only users with 'unfiltered_html'
        // capability can save arbitrary HTML via the CODE control in Elementor.
        // wp_kses_post() would strip scripts/iframes which defeats the widget's purpose.
        // The phpcs ignore is correct — document the intent clearly instead.
        echo $settings['html']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- intentional: HTML widget mirrors native Elementor HTML widget; unfiltered_html cap enforced by Elementor editor
    }
    ```
    
    ---
    
    
    
    **content_template() skeleton:**
    
    ```php
    protected function content_template(): void {
        ?>
        <# if ( settings.html ) { #>
            <div class="myplugin-html-wrapper">{{{ settings.html }}}</div>
        <# } #>
        <?php
    }
    ```
    
  • widget-icon-box.md 11.2 KB
    # Widget Boilerplate — Icon Box
    
    > **When to use this file:** Load whenever building a widget combining an icon with a title and description.
    > Verified against `elementor/includes/widgets/icon-box.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        // ── CONTENT ───────────────────────────────────────────────
        $this->start_controls_section( 'section_icon_box', [
            'label' => esc_html__( 'Icon Box', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $this->add_control( 'selected_icon', [
            'label'            => esc_html__( 'Icon', 'myplugin' ),
            'type'             => \Elementor\Controls_Manager::ICONS,
            'fa4compatibility' => 'icon',
            'default'          => [ 'value' => 'fas fa-star', 'library' => 'fa-solid' ],
        ] );
    
        $this->add_control( 'title_text', [
            'label'       => esc_html__( 'Title & Description', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::TEXT,
            'default'     => esc_html__( 'This is the heading', 'myplugin' ),
            'placeholder' => esc_html__( 'Enter your title', 'myplugin' ),
            'label_block' => true,
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $this->add_control( 'description_text', [
            'label'       => '',
            'type'        => \Elementor\Controls_Manager::TEXTAREA,
            'default'     => esc_html__( 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut elit tellus, luctus nec ullamcorper mattis pulvinar.', 'myplugin' ),
            'placeholder' => esc_html__( 'Enter your description', 'myplugin' ),
            'rows'        => 10,
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $this->add_control( 'link', [
            'label'     => esc_html__( 'Link', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::URL,
            'dynamic'   => [ 'active' => true ],
            'separator' => 'before',
        ] );
    
        $this->add_control( 'title_size', [
            'label'   => esc_html__( 'Title HTML Tag', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SELECT,
            'options' => [
                'h1' => 'H1', 'h2' => 'H2', 'h3' => 'H3',
                'h4' => 'H4', 'h5' => 'H5', 'h6' => 'H6',
                'div' => 'div', 'span' => 'span', 'p' => 'p',
            ],
            'default' => 'h3',
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE: Icon ───────────────────────────────────────────
        $this->start_controls_section( 'section_style_icon', [
            'label' => esc_html__( 'Icon', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_responsive_control( 'position', [
            'label'   => esc_html__( 'Position', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::CHOOSE,
            'default' => 'top',
            'options' => [
                'left'  => [ 'title' => esc_html__( 'Left',  'myplugin' ), 'icon' => 'eicon-h-align-left'  ],
                'top'   => [ 'title' => esc_html__( 'Top',   'myplugin' ), 'icon' => 'eicon-v-align-top'   ],
                'right' => [ 'title' => esc_html__( 'Right', 'myplugin' ), 'icon' => 'eicon-h-align-right' ],
            ],
            'prefix_class' => 'elementor-position%s-',
        ] );
    
        $this->add_responsive_control( 'icon_size', [
            'label'     => esc_html__( 'Size', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SLIDER,
            'range'     => [ 'px' => [ 'min' => 6, 'max' => 300 ] ],
            'default'   => [ 'size' => 50, 'unit' => 'px' ],
            'selectors' => [
                '{{WRAPPER}} .myplugin-icon-box-icon'     => 'font-size: {{SIZE}}{{UNIT}};',
                '{{WRAPPER}} .myplugin-icon-box-icon svg' => 'width: {{SIZE}}{{UNIT}}; height: {{SIZE}}{{UNIT}};',
            ],
        ] );
    
        $this->start_controls_tabs( 'icon_colors' );
    
            $this->start_controls_tab( 'icon_colors_normal', [ 'label' => esc_html__( 'Normal', 'myplugin' ) ] );
    
                $this->add_control( 'primary_color', [
                    'label'     => esc_html__( 'Primary Color', 'myplugin' ),
                    'type'      => \Elementor\Controls_Manager::COLOR,
                    'global'    => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_PRIMARY ],
                    'selectors' => [
                        '{{WRAPPER}} .myplugin-icon-box-icon i'   => 'color: {{VALUE}};',
                        '{{WRAPPER}} .myplugin-icon-box-icon svg' => 'fill: {{VALUE}};',
                    ],
                ] );
    
            $this->end_controls_tab();
    
            $this->start_controls_tab( 'icon_colors_hover', [ 'label' => esc_html__( 'Hover', 'myplugin' ) ] );
    
                $this->add_control( 'hover_primary_color', [
                    'label'     => esc_html__( 'Primary Color', 'myplugin' ),
                    'type'      => \Elementor\Controls_Manager::COLOR,
                    'selectors' => [
                        '{{WRAPPER}}:hover .myplugin-icon-box-icon i'   => 'color: {{VALUE}};',
                        '{{WRAPPER}}:hover .myplugin-icon-box-icon svg' => 'fill: {{VALUE}};',
                    ],
                ] );
    
            $this->end_controls_tab();
    
        $this->end_controls_tabs();
    
        $this->end_controls_section();
    
        // ── STYLE: Content ────────────────────────────────────────
        $this->start_controls_section( 'section_style_content', [
            'label' => esc_html__( 'Content', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_responsive_control( 'text_align', [
            'label'     => esc_html__( 'Alignment', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::CHOOSE,
            'options'   => [
                'left'   => [ 'title' => esc_html__( 'Left',   'myplugin' ), 'icon' => 'eicon-text-align-left'   ],
                'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ],
                'right'  => [ 'title' => esc_html__( 'Right',  'myplugin' ), 'icon' => 'eicon-text-align-right'  ],
            ],
            'selectors' => [ '{{WRAPPER}} .myplugin-icon-box-content' => 'text-align: {{VALUE}};' ],
        ] );
    
        $this->add_control( 'heading_title', [
            'label'     => esc_html__( 'Title', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::HEADING,
            'separator' => 'before',
        ] );
    
        $this->add_control( 'title_color', [
            'label'     => esc_html__( 'Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'global'    => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_PRIMARY ],
            'selectors' => [ '{{WRAPPER}} .myplugin-icon-box-title, {{WRAPPER}} .myplugin-icon-box-title a' => 'color: {{VALUE}};' ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Typography::get_type(),
            [
                'name'     => 'title_typography',
                'global'   => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_PRIMARY ],
                'selector' => '{{WRAPPER}} .myplugin-icon-box-title',
            ]
        );
    
        $this->add_control( 'heading_description', [
            'label'     => esc_html__( 'Description', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::HEADING,
            'separator' => 'before',
        ] );
    
        $this->add_control( 'description_color', [
            'label'     => esc_html__( 'Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'global'    => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_TEXT ],
            'selectors' => [ '{{WRAPPER}} .myplugin-icon-box-description' => 'color: {{VALUE}};' ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Typography::get_type(),
            [
                'name'     => 'description_typography',
                'global'   => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_TEXT ],
                'selector' => '{{WRAPPER}} .myplugin-icon-box-description',
            ]
        );
    
        $this->end_controls_section();
    }
    ```
    
    **render() skeleton:**
    
    ```php
    
    // ✅ REQUIRED on every widget — removes the redundant inner wrapper div.
    // Return true ONLY if your render() output physically requires the inner wrapper.
    // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ Return false to enable output caching for static widgets (same HTML for all users).
    // Return true if output varies per user, session, time, or random values.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
        $tag      = \Elementor\Utils::validate_html_tag( $settings['title_size'] );
    
        $title = $settings['title_text'];
        if ( ! empty( $settings['link']['url'] ) ) {
            $this->add_link_attributes( 'link', $settings['link'] );
            $title = '<a ' . $this->get_render_attribute_string( 'link' ) . '>' . esc_html( $title ) . '</a>';
        }
        ?>
        <div class="myplugin-icon-box">
            <div class="myplugin-icon-box-icon">
                <?php \Elementor\Icons_Manager::render_icon( $settings['selected_icon'], [ 'aria-hidden' => 'true' ] ); ?>
            </div>
            <div class="myplugin-icon-box-content">
                <<?php echo esc_attr( $tag ); ?> class="myplugin-icon-box-title">
                    <?php echo wp_kses_post( $title ); ?>
                </<?php echo esc_attr( $tag ); ?>>
                <?php if ( ! empty( $settings['description_text'] ) ) : ?>
                    <p class="myplugin-icon-box-description"><?php echo wp_kses_post( $settings['description_text'] ); ?></p>
                <?php endif; ?>
            </div>
        </div>
        <?php
    }
    ```
    
    > **Remove checklist:**
    > - `link` → remove if the box is never clickable
    > - `position` → remove if icon is always top-aligned
    > - Hover color tabs → remove if no hover state needed
    
    
    **content_template() skeleton:**
    
    ```php
    protected function content_template(): void {
        ?>
        <#
        var iconHTML = elementor.helpers.renderIcon( view, settings.selected_icon, { 'aria-hidden': true }, 'i', 'object' );
        var tag = elementor.helpers.validateHTMLTag( settings.title_size );
        // ✅ Escape user text/URL before it reaches {{{ title }}} (triple-brace needed for the <a>
        // wrapper) — otherwise it's an editor-context XSS wp.org rejects. See field-notes.md §2.
        var title = _.escape( settings.title_text );
        if ( settings.link && settings.link.url ) {
            title = '<a href="' + _.escape( settings.link.url ) + '">' + title + '</a>';
        }
        #>
        <div class="myplugin-icon-box">
            <div class="myplugin-icon-box-icon">{{{ iconHTML.value }}}</div>
            <div class="myplugin-icon-box-content">
                <{{{ tag }}} class="myplugin-icon-box-title">{{{ title }}}</{{{ tag }}}>
                <# if ( settings.description_text ) { #>
                    <p class="myplugin-icon-box-description">{{ settings.description_text }}</p>
                <# } #>
            </div>
        </div>
        <?php
    }
    ```
    
  • widget-icon-list.md 10.7 KB
    # Widget Boilerplate — Icon List
    
    > **When to use this file:** Load whenever building a bullet-list widget where each item has an icon, text, and optional link.
    > Verified against `elementor/includes/widgets/icon-list.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        // ── CONTENT ───────────────────────────────────────────────
        $this->start_controls_section( 'section_icon_list', [
            'label' => esc_html__( 'Icon List', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $this->add_control( 'view', [
            'label'        => esc_html__( 'Layout', 'myplugin' ),
            'type'         => \Elementor\Controls_Manager::CHOOSE,
            'default'      => 'traditional',
            'options'      => [
                'traditional' => [ 'title' => esc_html__( 'Default', 'myplugin' ), 'icon' => 'eicon-editor-list-ul' ],
                'inline'      => [ 'title' => esc_html__( 'Inline',  'myplugin' ), 'icon' => 'eicon-ellipsis-h'     ],
            ],
            'prefix_class' => 'elementor-icon-list--layout-',
        ] );
    
        $repeater = new \Elementor\Repeater();
    
        $repeater->add_control( 'text', [
            'label'       => esc_html__( 'Text', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::TEXT,
            'label_block' => true,
            'default'     => esc_html__( 'Text', 'myplugin' ),
            'placeholder' => esc_html__( 'List Item', 'myplugin' ),
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $repeater->add_control( 'selected_icon', [
            'label'            => esc_html__( 'Icon', 'myplugin' ),
            'type'             => \Elementor\Controls_Manager::ICONS,
            'default'          => [ 'value' => 'fas fa-check', 'library' => 'fa-solid' ],
            'fa4compatibility' => 'icon',
        ] );
    
        $repeater->add_control( 'link', [
            'label'   => esc_html__( 'Link', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::URL,
            'dynamic' => [ 'active' => true ],
        ] );
    
        $this->add_control( 'icon_list', [
            'label'       => '',
            'type'        => \Elementor\Controls_Manager::REPEATER,
            'fields'      => $repeater->get_controls(),
            'default'     => [
                [ 'text' => esc_html__( 'List Item #1', 'myplugin' ), 'selected_icon' => [ 'value' => 'fas fa-check', 'library' => 'fa-solid' ] ],
                [ 'text' => esc_html__( 'List Item #2', 'myplugin' ), 'selected_icon' => [ 'value' => 'fas fa-check', 'library' => 'fa-solid' ] ],
                [ 'text' => esc_html__( 'List Item #3', 'myplugin' ), 'selected_icon' => [ 'value' => 'fas fa-check', 'library' => 'fa-solid' ] ],
            ],
            'title_field' => '{{{ elementor.helpers.renderIcon( this, selected_icon, {}, "i", "panel" ) || \'<i class="\' + icon + \'"></i>\' }}} {{{ text }}}',
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE: List ───────────────────────────────────────────
        $this->start_controls_section( 'section_icon_list_style', [
            'label' => esc_html__( 'List', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_responsive_control( 'space_between', [
            'label'     => esc_html__( 'Space Between', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SLIDER,
            'range'     => [ 'px' => [ 'min' => 0, 'max' => 50 ] ],
            'default'   => [ 'size' => 5, 'unit' => 'px' ],
            'selectors' => [
                '{{WRAPPER}} .myplugin-icon-list-item:not(:last-child)' => 'padding-bottom: {{SIZE}}{{UNIT}};',
            ],
        ] );
    
        $this->add_control( 'divider', [
            'label'     => esc_html__( 'Divider', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SWITCHER,
            'separator' => 'before',
        ] );
    
        $this->add_control( 'divider_style', [
            'label'     => esc_html__( 'Style', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SELECT,
            'default'   => 'solid',
            'options'   => [
                'solid'  => esc_html__( 'Solid',  'myplugin' ),
                'double' => esc_html__( 'Double', 'myplugin' ),
                'dotted' => esc_html__( 'Dotted', 'myplugin' ),
                'dashed' => esc_html__( 'Dashed', 'myplugin' ),
            ],
            'condition' => [ 'divider' => 'yes' ],
            'selectors' => [
                '{{WRAPPER}} .myplugin-icon-list-item:not(:last-child)::after' => 'border-top-style: {{VALUE}};',
            ],
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE: Icon ───────────────────────────────────────────
        $this->start_controls_section( 'section_icon_style', [
            'label' => esc_html__( 'Icon', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_responsive_control( 'icon_size', [
            'label'     => esc_html__( 'Size', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SLIDER,
            'default'   => [ 'size' => 14, 'unit' => 'px' ],
            'range'     => [ 'px' => [ 'min' => 6, 'max' => 300 ] ],
            'selectors' => [
                '{{WRAPPER}} .myplugin-icon-list-icon i'   => 'font-size: {{SIZE}}{{UNIT}};',
                '{{WRAPPER}} .myplugin-icon-list-icon svg' => 'width: {{SIZE}}{{UNIT}}; height: {{SIZE}}{{UNIT}};',
            ],
        ] );
    
        $this->add_control( 'icon_color', [
            'label'     => esc_html__( 'Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'default'   => '',
            'selectors' => [
                '{{WRAPPER}} .myplugin-icon-list-icon i'   => 'color: {{VALUE}};',
                '{{WRAPPER}} .myplugin-icon-list-icon svg' => 'fill: {{VALUE}};',
            ],
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE: Text ───────────────────────────────────────────
        $this->start_controls_section( 'section_text_style', [
            'label' => esc_html__( 'Text', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_control( 'text_color', [
            'label'     => esc_html__( 'Text Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'default'   => '',
            'selectors' => [ '{{WRAPPER}} .myplugin-icon-list-text' => 'color: {{VALUE}};' ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Typography::get_type(),
            [
                'name'     => 'icon_typography',
                'selector' => '{{WRAPPER}} .myplugin-icon-list-item',
                'global'   => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_TEXT ],
            ]
        );
    
        $this->end_controls_section();
    }
    ```
    
    **render() skeleton:**
    
    ```php
    
    // ✅ REQUIRED on every widget — removes the redundant inner wrapper div.
    // Return true ONLY if your render() output physically requires the inner wrapper.
    // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ Return false to enable output caching for static widgets (same HTML for all users).
    // Return true if output varies per user, session, time, or random values.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
        ?>
        <ul class="myplugin-icon-list-items">
            <?php foreach ( $settings['icon_list'] as $index => $item ) :
                $repeater_key = $this->get_repeater_setting_key( 'text', 'icon_list', $index );
                $this->add_render_attribute( $repeater_key, 'class', 'myplugin-icon-list-text' );
                // ℹ️ For live in-panel text editing, you MAY also call
                // $this->add_inline_editing_attributes( $repeater_key, 'none' ) here. It is a current,
                // supported Elementor API (see SKILL.md §5) — not deprecated. Omitted by default to
                // keep the repeater output minimal.
    
                $tag = ! empty( $item['link']['url'] ) ? 'a' : 'span';
                if ( 'a' === $tag ) {
                    $link_key = $this->get_repeater_setting_key( 'link', 'icon_list', $index );
                    $this->add_link_attributes( $link_key, $item['link'] );
                }
                ?>
                <li class="myplugin-icon-list-item elementor-repeater-item-<?php echo esc_attr( $item['_id'] ); ?>">
                    <<?php echo esc_attr( $tag ); ?> <?php echo 'a' === $tag ? $this->get_render_attribute_string( $link_key ) : ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_render_attribute_string() returns pre-sanitized attribute markup ?>>
                        <?php if ( ! empty( $item['selected_icon']['value'] ) ) : ?>
                            <span class="myplugin-icon-list-icon">
                                <?php \Elementor\Icons_Manager::render_icon( $item['selected_icon'], [ 'aria-hidden' => 'true' ] ); ?>
                            </span>
                        <?php endif; ?>
                        <span <?php $this->print_render_attribute_string( $repeater_key ); ?>><?php echo esc_html( $item['text'] ); ?></span>
                    </<?php echo esc_attr( $tag ); ?>>
                </li>
            <?php endforeach; ?>
        </ul>
        <?php
    }
    ```
    
    > **Remove checklist:**
    > - `divider` + `divider_style` → remove if list items never need separators
    > - `link` in repeater → remove if list items are never linked
    
    
    **content_template() skeleton:**
    
    ```php
    protected function content_template(): void {
        ?>
        <# if ( ! settings.icon_list || ! settings.icon_list.length ) { return; } #>
        <ul class="myplugin-icon-list-items">
            <# _.each( settings.icon_list, function( item ) {
                var iconHTML = elementor.helpers.renderIcon( view, item.selected_icon, { 'aria-hidden': true }, 'i', 'object' );
                var tag = item.link && item.link.url ? 'a' : 'span';
                // ✅ Escape the URL — it lands in an attribute via {{{ linkAttr }}} (see field-notes.md §2).
                var linkAttr = 'a' === tag ? 'href="' + _.escape( item.link.url ) + '"' : '';
            #>
            <li class="myplugin-icon-list-item elementor-repeater-item-{{ item._id }}">
                <{{{ tag }}} {{{ linkAttr }}}>
                    <# if ( iconHTML && iconHTML.value ) { #>
                        <span class="myplugin-icon-list-icon">{{{ iconHTML.value }}}</span>
                    <# } #>
                    <span class="myplugin-icon-list-text">{{ item.text }}</span>
                </{{{ tag }}}>
            </li>
            <# } ); #>
        </ul>
        <?php
    }
    ```
    
  • widget-icon.md 7.9 KB
    # Widget Boilerplate — Icon
    
    > **When to use this file:** Load whenever building a widget that displays a single standalone icon.
    > Verified against `elementor/includes/widgets/icon.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        // ── CONTENT ───────────────────────────────────────────────
        $this->start_controls_section( 'section_icon', [
            'label' => esc_html__( 'Icon', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $this->add_control( 'selected_icon', [
            'label'            => esc_html__( 'Icon', 'myplugin' ),
            'type'             => \Elementor\Controls_Manager::ICONS,
            'fa4compatibility' => 'icon',
            'default'          => [ 'value' => 'fas fa-star', 'library' => 'fa-solid' ],
        ] );
    
        $this->add_control( 'link', [
            'label'     => esc_html__( 'Link', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::URL,
            'dynamic'   => [ 'active' => true ],
            'separator' => 'before',
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE ─────────────────────────────────────────────────
        $this->start_controls_section( 'section_style_icon', [
            'label' => esc_html__( 'Icon', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_responsive_control( 'align', [
            'label'     => esc_html__( 'Alignment', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::CHOOSE,
            'options'   => [
                'left'   => [ 'title' => esc_html__( 'Left',   'myplugin' ), 'icon' => 'eicon-text-align-left'   ],
                'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ],
                'right'  => [ 'title' => esc_html__( 'Right',  'myplugin' ), 'icon' => 'eicon-text-align-right'  ],
            ],
            'selectors' => [ '{{WRAPPER}}' => 'text-align: {{VALUE}};' ],
        ] );
    
        $this->start_controls_tabs( 'icon_colors' );
    
            $this->start_controls_tab( 'icon_colors_normal', [
                'label' => esc_html__( 'Normal', 'myplugin' ),
            ] );
    
                $this->add_control( 'primary_color', [
                    'label'     => esc_html__( 'Primary Color', 'myplugin' ),
                    'type'      => \Elementor\Controls_Manager::COLOR,
                    'default'   => '',
                    'selectors' => [
                        '{{WRAPPER}} .myplugin-icon i'   => 'color: {{VALUE}};',
                        '{{WRAPPER}} .myplugin-icon svg' => 'fill: {{VALUE}};',
                    ],
                    'global'    => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_PRIMARY ],
                ] );
    
            $this->end_controls_tab();
    
            $this->start_controls_tab( 'icon_colors_hover', [
                'label' => esc_html__( 'Hover', 'myplugin' ),
            ] );
    
                $this->add_control( 'hover_primary_color', [
                    'label'     => esc_html__( 'Primary Color', 'myplugin' ),
                    'type'      => \Elementor\Controls_Manager::COLOR,
                    'default'   => '',
                    'selectors' => [
                        '{{WRAPPER}} .myplugin-icon:hover i'   => 'color: {{VALUE}};',
                        '{{WRAPPER}} .myplugin-icon:hover svg' => 'fill: {{VALUE}};',
                    ],
                ] );
    
                // ⛔ hover_animation excluded by default — loads animation CSS library
                // $this->add_control( 'hover_animation', [ 'type' => \Elementor\Controls_Manager::HOVER_ANIMATION ] );
    
            $this->end_controls_tab();
    
        $this->end_controls_tabs();
    
        $this->add_responsive_control( 'size', [
            'label'     => esc_html__( 'Size', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SLIDER,
            'range'     => [ 'px' => [ 'min' => 6, 'max' => 300 ] ],
            'default'   => [ 'size' => 50, 'unit' => 'px' ],
            'selectors' => [
                '{{WRAPPER}} .myplugin-icon'     => 'font-size: {{SIZE}}{{UNIT}};',
                '{{WRAPPER}} .myplugin-icon svg' => 'width: {{SIZE}}{{UNIT}}; height: {{SIZE}}{{UNIT}};',
            ],
            'separator' => 'before',
        ] );
    
        $this->add_responsive_control( 'icon_padding', [
            'label'     => esc_html__( 'Padding', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SLIDER,
            'range'     => [ 'em' => [ 'min' => 0, 'max' => 5 ] ],
            'default'   => [ 'unit' => 'em' ],
            'selectors' => [
                '{{WRAPPER}} .myplugin-icon' => 'padding: {{SIZE}}{{UNIT}};',
            ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Border::get_type(),
            [
                'name'      => 'border',
                'selector'  => '{{WRAPPER}} .myplugin-icon',
                'separator' => 'before',
            ]
        );
    
        $this->add_responsive_control( 'border_radius', [
            'label'      => esc_html__( 'Border Radius', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::DIMENSIONS,
            'size_units' => [ 'px', '%', 'em', 'rem' ],
            'selectors'  => [
                '{{WRAPPER}} .myplugin-icon' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};',
            ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Box_Shadow::get_type(),
            [
                'name'     => 'box_shadow',
                'selector' => '{{WRAPPER}} .myplugin-icon',
            ]
        );
    
        $this->end_controls_section();
    }
    ```
    
    **render() skeleton:**
    
    ```php
    
    // ✅ REQUIRED on every widget — removes the redundant inner wrapper div.
    // Return true ONLY if your render() output physically requires the inner wrapper.
    // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ Return false to enable output caching for static widgets (same HTML for all users).
    // Return true if output varies per user, session, time, or random values.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
    
        if ( empty( $settings['selected_icon']['value'] ) ) {
            return;
        }
    
        $icon_tag = 'div';
        if ( ! empty( $settings['link']['url'] ) ) {
            $this->add_link_attributes( 'link', $settings['link'] );
            $icon_tag = 'a';
        }
        ?>
        <<?php echo esc_attr( $icon_tag ); ?> class="myplugin-icon" <?php echo 'a' === $icon_tag ? $this->get_render_attribute_string( 'link' ) : ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_render_attribute_string() returns pre-sanitized attribute markup ?>>
            <?php \Elementor\Icons_Manager::render_icon( $settings['selected_icon'], [ 'aria-hidden' => 'true' ] ); ?>
        </<?php echo esc_attr( $icon_tag ); ?>>
        <?php
    }
    ```
    
    > **Remove checklist:**
    > - `link` → remove if icon is decorative only
    > - `border` + `border_radius` + `box_shadow` → remove for plain icon-only use
    > - `icon_padding` → remove if icon has no background or border
    > - `hover_animation` → excluded by default
    
    
    **content_template() skeleton:**
    
    ```php
    protected function content_template(): void {
        ?>
        <#
        if ( ! settings.selected_icon || ! settings.selected_icon.value ) { return; }
        var iconHTML = elementor.helpers.renderIcon( view, settings.selected_icon, { 'aria-hidden': true }, 'i', 'object' );
        var tag = settings.link && settings.link.url ? 'a' : 'div';
        // ✅ Escape the URL — it lands in an attribute via {{{ linkAttr }}} (see field-notes.md §2).
        var linkAttr = 'a' === tag ? 'href="' + _.escape( settings.link.url ) + '"' : '';
        #>
        <{{{ tag }}} class="myplugin-icon" {{{ linkAttr }}}>
            {{{ iconHTML.value }}}
        </{{{ tag }}}>
        <?php
    }
    ```
    
  • widget-image-box.md 10.7 KB
    # Widget Boilerplate — Image Box
    
    > **When to use this file:** Load whenever building a widget combining an image with a title and description.
    > Verified against `elementor/includes/widgets/image-box.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        // ── CONTENT ───────────────────────────────────────────────
        $this->start_controls_section( 'section_image_box', [
            'label' => esc_html__( 'Image Box', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $this->add_control( 'image', [
            'label'   => esc_html__( 'Choose Image', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::MEDIA,
            'default' => [ 'url' => \Elementor\Utils::get_placeholder_image_src() ],
            'dynamic' => [ 'active' => true ],
        ] );
    
        // ✅ REQUIRED — get_attachment_image_html() in render() reads 'image_size' and
        // 'image_custom_dimension' generated by this group control. Without it, render() will fail.
        $this->add_group_control(
            \Elementor\Group_Control_Image_Size::get_type(),
            [
                'name'      => 'image',    // generates 'image_size' + 'image_custom_dimension'
                'default'   => 'large',
                'separator' => 'none',
            ]
        );
    
        $this->add_control( 'title_text', [
            'label'       => esc_html__( 'Title & Description', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::TEXT,
            'default'     => esc_html__( 'This is the heading', 'myplugin' ),
            'placeholder' => esc_html__( 'Enter your title', 'myplugin' ),
            'label_block' => true,
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $this->add_control( 'description_text', [
            'label'       => '',
            'type'        => \Elementor\Controls_Manager::TEXTAREA,
            'default'     => esc_html__( 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut elit tellus, luctus nec ullamcorper mattis pulvinar.', 'myplugin' ),
            'placeholder' => esc_html__( 'Enter your description', 'myplugin' ),
            'rows'        => 10,
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $this->add_control( 'link', [
            'label'     => esc_html__( 'Link', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::URL,
            'dynamic'   => [ 'active' => true ],
            'separator' => 'before',
        ] );
    
        $this->add_control( 'title_size', [
            'label'   => esc_html__( 'Title HTML Tag', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SELECT,
            'options' => [
                'h1' => 'H1', 'h2' => 'H2', 'h3' => 'H3',
                'h4' => 'H4', 'h5' => 'H5', 'h6' => 'H6',
                'div' => 'div', 'span' => 'span', 'p' => 'p',
            ],
            'default' => 'h3',
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE: Image ──────────────────────────────────────────
        $this->start_controls_section( 'section_style_image', [
            'label' => esc_html__( 'Image', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_responsive_control( 'image_space', [
            'label'     => esc_html__( 'Spacing', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SLIDER,
            'default'   => [ 'size' => 15, 'unit' => 'px' ],
            'range'     => [ 'px' => [ 'min' => 0, 'max' => 100 ] ],
            'selectors' => [ '{{WRAPPER}} .myplugin-image-box-img' => 'margin-bottom: {{SIZE}}{{UNIT}};' ],
        ] );
    
        $this->add_responsive_control( 'image_size', [
            'label'      => esc_html__( 'Width', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::SLIDER,
            'default'    => [ 'unit' => '%' ],
            'size_units' => [ 'px', '%', 'vw' ],
            'range'      => [
                '%'  => [ 'min' => 5,  'max' => 100 ],
                'px' => [ 'min' => 1,  'max' => 500 ],
            ],
            'selectors'  => [ '{{WRAPPER}} .myplugin-image-box-img img' => 'width: {{SIZE}}{{UNIT}};' ],
        ] );
    
        $this->add_responsive_control( 'image_border_radius', [
            'label'      => esc_html__( 'Border Radius', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::DIMENSIONS,
            'size_units' => [ 'px', '%', 'em', 'rem' ],
            'selectors'  => [
                '{{WRAPPER}} .myplugin-image-box-img img' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};',
            ],
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE: Content ────────────────────────────────────────
        $this->start_controls_section( 'section_style_content', [
            'label' => esc_html__( 'Content', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_responsive_control( 'text_align', [
            'label'     => esc_html__( 'Alignment', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::CHOOSE,
            'options'   => [
                'left'   => [ 'title' => esc_html__( 'Left',   'myplugin' ), 'icon' => 'eicon-text-align-left'   ],
                'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ],
                'right'  => [ 'title' => esc_html__( 'Right',  'myplugin' ), 'icon' => 'eicon-text-align-right'  ],
            ],
            'selectors' => [ '{{WRAPPER}} .myplugin-image-box-content' => 'text-align: {{VALUE}};' ],
        ] );
    
        $this->add_control( 'heading_title', [
            'label'     => esc_html__( 'Title', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::HEADING,
            'separator' => 'before',
        ] );
    
        $this->add_control( 'title_color', [
            'label'     => esc_html__( 'Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'global'    => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_PRIMARY ],
            'selectors' => [ '{{WRAPPER}} .myplugin-image-box-title, {{WRAPPER}} .myplugin-image-box-title a' => 'color: {{VALUE}};' ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Typography::get_type(),
            [
                'name'     => 'title_typography',
                'global'   => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_PRIMARY ],
                'selector' => '{{WRAPPER}} .myplugin-image-box-title',
            ]
        );
    
        $this->add_control( 'heading_description', [
            'label'     => esc_html__( 'Description', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::HEADING,
            'separator' => 'before',
        ] );
    
        $this->add_control( 'description_color', [
            'label'     => esc_html__( 'Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'global'    => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_TEXT ],
            'selectors' => [ '{{WRAPPER}} .myplugin-image-box-description' => 'color: {{VALUE}};' ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Typography::get_type(),
            [
                'name'     => 'description_typography',
                'global'   => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_TEXT ],
                'selector' => '{{WRAPPER}} .myplugin-image-box-description',
            ]
        );
    
        $this->end_controls_section();
    }
    ```
    
    **render() skeleton:**
    
    ```php
    
    // ✅ REQUIRED on every widget — removes the redundant inner wrapper div.
    // Return true ONLY if your render() output physically requires the inner wrapper.
    // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ Return false to enable output caching for static widgets (same HTML for all users).
    // Return true if output varies per user, session, time, or random values.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
        $tag      = \Elementor\Utils::validate_html_tag( $settings['title_size'] );
    
        $image_html = \Elementor\Group_Control_Image_Size::get_attachment_image_html( $settings );
    
        $title = esc_html( $settings['title_text'] );
        if ( ! empty( $settings['link']['url'] ) ) {
            $this->add_link_attributes( 'link', $settings['link'] );
            $title = '<a ' . $this->get_render_attribute_string( 'link' ) . '>' . $title . '</a>';
        }
        ?>
        <div class="myplugin-image-box">
            <div class="myplugin-image-box-img">
                <?php echo wp_kses_post( $image_html ); ?>
            </div>
            <div class="myplugin-image-box-content">
                <<?php echo esc_attr( $tag ); ?> class="myplugin-image-box-title">
                    <?php echo wp_kses_post( $title ); ?>
                </<?php echo esc_attr( $tag ); ?>>
                <?php if ( ! empty( $settings['description_text'] ) ) : ?>
                    <p class="myplugin-image-box-description"><?php echo wp_kses_post( $settings['description_text'] ); ?></p>
                <?php endif; ?>
            </div>
        </div>
        <?php
    }
    ```
    
    > **Remove checklist:**
    > - `link` → remove if the box is never clickable
    > - `image_border_radius` → remove for always-square images
    
    
    **content_template() skeleton:**
    
    ```php
    protected function content_template(): void {
        ?>
        <#
        var image = {
            id: settings.image.id, url: settings.image.url,
            size: settings.image_size, dimension: settings.image_custom_dimension,
            model: view.model, // ✅ view.model replaces deprecated view.getEditModel() (removed Elementor 3.x)
        };
        var image_url = elementor.imagesManager.getImageUrl( image );
        var tag   = elementor.helpers.validateHTMLTag( settings.title_size );
        // ✅ Escape user text/URL before it reaches {{{ title }}} (triple-brace needed for the <a>
        // wrapper) — otherwise it's an editor-context XSS wp.org rejects. See field-notes.md §2.
        var title = _.escape( settings.title_text );
        if ( settings.link && settings.link.url ) {
            title = '<a href="' + _.escape( settings.link.url ) + '">' + title + '</a>';
        }
        #>
        <div class="myplugin-image-box">
            <div class="myplugin-image-box-img"><img src="{{ image_url }}" alt=""></div>
            <div class="myplugin-image-box-content">
                <{{{ tag }}} class="myplugin-image-box-title">{{{ title }}}</{{{ tag }}}>
                <# if ( settings.description_text ) { #>
                    <p class="myplugin-image-box-description">{{ settings.description_text }}</p>
                <# } #>
            </div>
        </div>
        <?php
    }
    ```
    
  • widget-image-carousel.md 14.3 KB
    # Widget Boilerplate — Image Carousel
    
    > **When to use this file:** Load whenever building a widget showing a rotating image slider/carousel.
    > Verified against `elementor/includes/widgets/image-carousel.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    > ✅ Relies on Swiper JS (bundled with Elementor) for carousel behaviour.
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        // ── CONTENT ───────────────────────────────────────────────
        $this->start_controls_section( 'section_image_carousel', [
            'label' => esc_html__( 'Image Carousel', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $this->add_control( 'carousel', [
            'label'   => esc_html__( 'Add Images', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::GALLERY,
            'default' => [],
            'dynamic' => [ 'active' => true ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Image_Size::get_type(),
            [ 'name' => 'thumbnail', 'default' => 'medium' ]
        );
    
        $this->add_responsive_control( 'slides_to_show', [
            'label'   => esc_html__( 'Slides to Show', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SELECT,
            'default' => '3',
            'options' => [ '1' => '1', '2' => '2', '3' => '3', '4' => '4', '5' => '5', '6' => '6' ],
        ] );
    
        $this->add_control( 'slides_to_scroll', [
            'label'   => esc_html__( 'Slides to Scroll', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SELECT,
            'default' => '1',
            'options' => [ '1' => '1', '2' => '2', '3' => '3' ],
        ] );
    
        $this->add_control( 'image_carousel_link', [
            'label'   => esc_html__( 'Link', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SELECT,
            'default' => 'none',
            'options' => [
                'none'   => esc_html__( 'None',        'myplugin' ),
                'file'   => esc_html__( 'Media File',  'myplugin' ),
                'custom' => esc_html__( 'Custom URL',  'myplugin' ),
            ],
        ] );
    
        $this->add_control( 'link_to', [
            'label'       => esc_html__( 'Link', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::URL,
            'placeholder' => esc_html__( 'https://your-link.com', 'myplugin' ),
            'condition'   => [ 'image_carousel_link' => 'custom' ],
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $this->add_control( 'open_lightbox', [
            'label'     => esc_html__( 'Lightbox', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SELECT,
            'default'   => 'default',
            'options'   => [
                'default' => esc_html__( 'Default', 'myplugin' ),
                'yes'     => esc_html__( 'Yes',     'myplugin' ),
                'no'      => esc_html__( 'No',      'myplugin' ),
            ],
            'condition' => [ 'image_carousel_link' => 'file' ],
        ] );
    
        $this->end_controls_section();
    
        // ── CONTENT: Additional Options ───────────────────────────
        $this->start_controls_section( 'section_additional_options', [
            'label' => esc_html__( 'Additional Options', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $this->add_control( 'autoplay', [
            'label'   => esc_html__( 'Autoplay', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SWITCHER,
            'default' => 'yes',
        ] );
    
        $this->add_control( 'pause_on_hover', [
            'label'     => esc_html__( 'Pause on Hover', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SWITCHER,
            'default'   => 'yes',
            'condition' => [ 'autoplay' => 'yes' ],
        ] );
    
        $this->add_control( 'autoplay_speed', [
            'label'     => esc_html__( 'Autoplay Speed', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::NUMBER,
            'default'   => 5000,
            'condition' => [ 'autoplay' => 'yes' ],
        ] );
    
        $this->add_control( 'infinite', [
            'label'   => esc_html__( 'Infinite Loop', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SWITCHER,
            'default' => 'yes',
        ] );
    
        $this->add_control( 'transition', [
            'label'   => esc_html__( 'Transition', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SELECT,
            'default' => 'slide',
            'options' => [
                'slide' => esc_html__( 'Slide', 'myplugin' ),
                'fade'  => esc_html__( 'Fade',  'myplugin' ),
            ],
        ] );
    
        $this->add_control( 'transition_speed', [
            'label'   => esc_html__( 'Transition Speed', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::NUMBER,
            'default' => 500,
        ] );
    
        $this->add_control( 'navigation', [
            'label'   => esc_html__( 'Navigation', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SELECT,
            'default' => 'arrows',
            'options' => [
                'both'   => esc_html__( 'Arrows and Dots', 'myplugin' ),
                'arrows' => esc_html__( 'Arrows',           'myplugin' ),
                'dots'   => esc_html__( 'Dots',             'myplugin' ),
                'none'   => esc_html__( 'None',             'myplugin' ),
            ],
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE ─────────────────────────────────────────────────
        $this->start_controls_section( 'section_style_navigation', [
            'label' => esc_html__( 'Navigation', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_control( 'heading_arrows', [
            'label' => esc_html__( 'Arrows', 'myplugin' ),
            'type'  => \Elementor\Controls_Manager::HEADING,
        ] );
    
        $this->add_responsive_control( 'arrows_size', [
            'label'     => esc_html__( 'Size', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SLIDER,
            'default'   => [ 'size' => 20, 'unit' => 'px' ],
            'range'     => [ 'px' => [ 'min' => 10, 'max' => 100 ] ],
            'selectors' => [ '{{WRAPPER}} .swiper-button-prev, {{WRAPPER}} .swiper-button-next' => 'font-size: {{SIZE}}{{UNIT}};' ],
        ] );
    
        $this->add_control( 'arrows_color', [
            'label'     => esc_html__( 'Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'selectors' => [ '{{WRAPPER}} .swiper-button-prev, {{WRAPPER}} .swiper-button-next' => 'color: {{VALUE}};' ],
        ] );
    
        $this->end_controls_section();
    
        $this->start_controls_section( 'section_style_image', [
            'label' => esc_html__( 'Image', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_responsive_control( 'image_border_radius', [
            'label'      => esc_html__( 'Border Radius', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::DIMENSIONS,
            'size_units' => [ 'px', '%', 'em', 'rem' ],
            'selectors'  => [
                '{{WRAPPER}} .myplugin-carousel-image' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};',
            ],
        ] );
    
        $this->end_controls_section();
    }
    ```
    
    > **Remove checklist:**
    > - `open_lightbox` → remove if carousel images don't link to media file
    > - `pause_on_hover` → remove if autoplay is always off
    > - Navigation style section → remove if navigation is always `none`
    
    **render() + content_template() skeleton:**
    
    ```php
    // ✅ REQUIRED: Declare Swiper as a dependency so Elementor enqueues it only on pages
    // using this widget. Swiper is bundled with Elementor — do NOT register it yourself.
    // Source: developers.elementor.com/docs/widgets/widget-dependencies/
    public function get_script_depends(): array {
        return [ 'swiper' ];
    }
    
    public function get_style_depends(): array {
        return [ 'swiper' ];
    }
    
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
    
        if ( empty( $settings['carousel'] ) ) {
            return;
        }
    
        $show_arrows = in_array( $settings['navigation'], [ 'arrows', 'both' ], true );
        $show_dots   = in_array( $settings['navigation'], [ 'dots', 'both' ], true );
    
        // ✅ Swiper is bundled with Elementor — declare 'swiper' as a script dependency
        // in get_script_depends() so Elementor loads it only on pages using this widget.
        $this->add_render_attribute( 'carousel-wrapper', [
            'class'                => 'myplugin-image-carousel swiper',
            'data-autoplay'        => 'yes' === $settings['autoplay'] ? $settings['autoplay_speed'] : '0',
            'data-loop'            => 'yes' === $settings['infinite'] ? 'true' : 'false',
            'data-slides-to-show'  => $settings['slides_to_show'],
            'data-slides-to-scroll'=> $settings['slides_to_scroll'],
            'data-effect'          => $settings['transition'],
            'data-speed'           => $settings['transition_speed'],
            'data-pause-on-hover'  => 'yes' === ( $settings['pause_on_hover'] ?? '' ) ? 'true' : 'false',
        ] );
        ?>
        <div class="myplugin-carousel-wrapper">
            <div <?php $this->print_render_attribute_string( 'carousel-wrapper' ); ?>>
                <div class="swiper-wrapper">
                    <?php foreach ( $settings['carousel'] as $attachment ) :
                        $image_html = \Elementor\Group_Control_Image_Size::get_attachment_image_html( $settings, 'thumbnail', $attachment );
                        if ( empty( $image_html ) ) continue;
    
                        $link_tag_open  = '';
                        $link_tag_close = '';
    
                        if ( 'file' === $settings['image_carousel_link'] ) {
                            $open_lb = esc_attr( $settings['open_lightbox'] ?? 'default' );
                            $link_tag_open  = '<a href="' . esc_url( wp_get_attachment_url( $attachment['id'] ) ) . '" data-elementor-open-lightbox="' . $open_lb . '">';
                            $link_tag_close = '</a>';
                        } elseif ( 'custom' === $settings['image_carousel_link'] && ! empty( $settings['link_to']['url'] ) ) {
                            $this->add_link_attributes( 'carousel-link', $settings['link_to'] );
                            $link_tag_open  = '<a ' . $this->get_render_attribute_string( 'carousel-link' ) . '>';
                            $link_tag_close = '</a>';
                        }
                        ?>
                        <div class="swiper-slide">
                            <figure class="myplugin-carousel-image">
                                <?php echo $link_tag_open; // phpcs:ignore WordPress.Security.EscapeOutput ?>
                                    <?php echo wp_kses_post( $image_html ); ?>
                                <?php echo $link_tag_close; // phpcs:ignore WordPress.Security.EscapeOutput ?>
                            </figure>
                        </div>
                    <?php endforeach; ?>
                </div>
    
                <?php if ( $show_dots ) : ?>
                    <div class="swiper-pagination"></div>
                <?php endif; ?>
            </div>
    
            <?php if ( $show_arrows ) : ?>
                <div class="swiper-button-prev"></div>
                <div class="swiper-button-next"></div>
            <?php endif; ?>
        </div>
        <?php
    }
    
    protected function content_template(): void {
        ?>
        <#
        if ( ! settings.carousel || ! settings.carousel.length ) { return; }
        var showArrows = -1 !== [ 'arrows', 'both' ].indexOf( settings.navigation );
        var showDots   = -1 !== [ 'dots',   'both' ].indexOf( settings.navigation );
        #>
        <div class="myplugin-carousel-wrapper">
            <div class="myplugin-image-carousel swiper">
                <div class="swiper-wrapper">
                    <# _.each( settings.carousel, function( attachment ) { #>
                    <div class="swiper-slide">
                        <figure class="myplugin-carousel-image">
                            <img src="{{ attachment.url }}" alt="">
                        </figure>
                    </div>
                    <# } ); #>
                </div>
                <# if ( showDots ) { #><div class="swiper-pagination"></div><# } #>
            </div>
            <# if ( showArrows ) { #>
                <div class="swiper-button-prev"></div>
                <div class="swiper-button-next"></div>
            <# } #>
        </div>
        <?php
    }
    ```
    
    > **Script dependency note:** Add `'swiper'` to `get_script_depends()` so Elementor loads
    > the bundled Swiper JS only on pages containing this widget.
    
    > ⚠️ **Required: Swiper JS initialization.** The `.swiper` markup alone renders as a static list.
    > You MUST initialize Swiper via JS in your widget's frontend script. Declare `'swiper'` and
    > `'elementor-frontend'` as dependencies in `get_script_depends()`, then initialize per-instance
    > inside the `elementor/frontend/init` handler:
    > ```js
    > ( () => {
    >   'use strict';
    >   const init = ( scope ) => {
    >     const el = scope.querySelector( '.myplugin-image-carousel.swiper' );
    >     if ( ! el ) return;
    >     const nav  = scope.dataset.navigation || 'none';  // read from data-* if needed
    >     new Swiper( el, {
    >       slidesPerView: 1,
    >       loop:          true,
    >       navigation: ( nav === 'arrows' || nav === 'both' ) ? {
    >         nextEl: scope.querySelector( '.swiper-button-next' ),
    >         prevEl: scope.querySelector( '.swiper-button-prev' ),
    >       } : false,
    >       pagination: ( nav === 'dots' || nav === 'both' ) ? {
    >         el:        scope.querySelector( '.swiper-pagination' ),
    >         clickable: true,
    >       } : false,
    >       // ✅ Respect prefers-reduced-motion — do not autoplay animations for users who opt out
    >       autoplay: window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches
    >         ? false
    >         : { delay: 3000, disableOnInteraction: true },
    >     } );
    >   };
    >
    >   window.addEventListener( 'elementor/frontend/init', () => {
    >     window.elementorFrontend.hooks.addAction(
    >       'frontend/element_ready/myplugin-widget.default',
    >       ( $scope ) => {
    >         if ( ! $scope || ! $scope[0] ) return;
    >         init( $scope[0] );
    >       }
    >     );
    >   } );
    > } )();
    > ```
    > **Swiper version note:** Elementor bundles Swiper 8.x (class `.swiper`, NOT the legacy
    > `.swiper-container`). Do NOT register your own Swiper copy — use `'swiper'` as a dependency
    > to guarantee version consistency with Elementor's bundled copy.
    
  • widget-image-gallery.md 6.9 KB
    # Widget Boilerplate — Image Gallery
    
    > **When to use this file:** Load whenever building a widget that displays a grid of images.
    > Verified against `elementor/includes/widgets/image-gallery.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        // ── CONTENT ───────────────────────────────────────────────
        $this->start_controls_section( 'section_gallery', [
            'label' => esc_html__( 'Image Gallery', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        // ✅ GALLERY control — stores array of [ 'id' => int, 'url' => string ]
        $this->add_control( 'wp_gallery', [
            'label'   => esc_html__( 'Add Images', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::GALLERY,
            'default' => [],
            'dynamic' => [ 'active' => true ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Image_Size::get_type(),
            [
                'name'    => 'thumbnail',
                'default' => 'medium',
            ]
        );
    
        $this->add_responsive_control( 'gallery_columns', [
            'label'   => esc_html__( 'Columns', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SELECT,
            'default' => '3',
            'options' => [ '1' => '1', '2' => '2', '3' => '3', '4' => '4', '5' => '5', '6' => '6' ],
        ] );
    
        $this->add_control( 'gallery_link', [
            'label'   => esc_html__( 'Link', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SELECT,
            'default' => 'file',
            'options' => [
                'file'  => esc_html__( 'Media File', 'myplugin' ),
                'none'  => esc_html__( 'None',        'myplugin' ),
            ],
        ] );
    
        $this->add_control( 'open_lightbox', [
            'label'     => esc_html__( 'Lightbox', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SELECT,
            'default'   => 'default',
            'options'   => [
                'default' => esc_html__( 'Default', 'myplugin' ),
                'yes'     => esc_html__( 'Yes',     'myplugin' ),
                'no'      => esc_html__( 'No',      'myplugin' ),
            ],
            'condition' => [ 'gallery_link' => 'file' ],
        ] );
    
        $this->add_control( 'gallery_rand', [
            'label'   => esc_html__( 'Ordering', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SELECT,
            'default' => '',
            'options' => [
                ''     => esc_html__( 'Default',  'myplugin' ),
                'rand' => esc_html__( 'Random',   'myplugin' ),
            ],
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE ─────────────────────────────────────────────────
        $this->start_controls_section( 'section_gallery_images_style', [
            'label' => esc_html__( 'Images', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_responsive_control( 'columns_gap', [
            'label'     => esc_html__( 'Columns Gap', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SLIDER,
            'range'     => [ 'px' => [ 'min' => 0, 'max' => 100 ] ],
            'default'   => [ 'size' => 10, 'unit' => 'px' ],
            'selectors' => [ '{{WRAPPER}} .myplugin-gallery-item' => 'padding-right: calc( {{SIZE}}{{UNIT}}/2 ); padding-left: calc( {{SIZE}}{{UNIT}}/2 );' ],
        ] );
    
        $this->add_responsive_control( 'rows_gap', [
            'label'     => esc_html__( 'Rows Gap', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SLIDER,
            'range'     => [ 'px' => [ 'min' => 0, 'max' => 100 ] ],
            'default'   => [ 'size' => 10, 'unit' => 'px' ],
            'selectors' => [ '{{WRAPPER}} .myplugin-gallery-item' => 'padding-bottom: {{SIZE}}{{UNIT}};' ],
        ] );
    
        $this->add_responsive_control( 'image_border_radius', [
            'label'      => esc_html__( 'Border Radius', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::DIMENSIONS,
            'size_units' => [ 'px', '%', 'em', 'rem' ],
            'selectors'  => [
                '{{WRAPPER}} .myplugin-gallery-item img' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};',
            ],
        ] );
    
        $this->end_controls_section();
    }
    ```
    
    **render() skeleton:**
    
    ```php
    
    // ✅ REQUIRED on every widget — removes the redundant inner wrapper div.
    // Return true ONLY if your render() output physically requires the inner wrapper.
    // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ Return true when 'Random' ordering is selected — shuffle() produces different output
    // every render, so output caching must be bypassed to avoid freezing one random order forever.
    // Return false for fixed ordering (Default) to benefit from output caching.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return 'rand' === $this->get_settings( 'gallery_rand' );
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
    
        if ( empty( $settings['wp_gallery'] ) ) {
            return;
        }
    
        $gallery = $settings['wp_gallery'];
        if ( 'rand' === $settings['gallery_rand'] ) {
            shuffle( $gallery );
        }
        ?>
        <div class="myplugin-gallery myplugin-gallery-columns-<?php echo esc_attr( $settings['gallery_columns'] ); ?>">
            <?php foreach ( $gallery as $attachment ) :
                $image_html = \Elementor\Group_Control_Image_Size::get_attachment_image_html( $settings, 'thumbnail', $attachment );
                if ( empty( $image_html ) ) continue;
    
                $link_tag_open  = '';
                $link_tag_close = '';
                if ( 'file' === $settings['gallery_link'] ) {
                    $link_tag_open  = '<a href="' . esc_url( wp_get_attachment_url( $attachment['id'] ) ) . '" data-elementor-open-lightbox="' . esc_attr( $settings['open_lightbox'] ) . '">';
                    $link_tag_close = '</a>';
                }
                ?>
                <div class="myplugin-gallery-item">
                    <?php echo $link_tag_open; // phpcs:ignore ?>
                        <?php echo wp_kses_post( $image_html ); ?>
                    <?php echo $link_tag_close; // phpcs:ignore ?>
                </div>
            <?php endforeach; ?>
        </div>
        <?php
    }
    ```
    
    
    
    **content_template() skeleton:**
    
    ```php
    protected function content_template(): void {
        ?>
        <# if ( ! settings.wp_gallery || ! settings.wp_gallery.length ) { return; } #>
        <div class="myplugin-gallery myplugin-gallery-columns-{{ settings.gallery_columns }}">
            <# _.each( settings.wp_gallery, function( attachment ) { #>
            <div class="myplugin-gallery-item">
                <img src="{{ attachment.url }}" alt="">
            </div>
            <# } ); #>
        </div>
        <?php
    }
    ```
    
  • widget-image.md 17 KB
    # Widget Boilerplate — Image
    
    > **When to use this file:** Load this boilerplate whenever building a widget that includes
    > a primary image element — hero images, thumbnails, team photos, logos, or any single focal
    > image with optional caption and link.
    >
    > Verified against `elementor/includes/widgets/image.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    > Uses `Group_Control_Image_Size` + `get_attachment_image_html()` — the correct Elementor-native
    > approach. Never use a hardcoded SELECT or `wp_get_attachment_image()` directly.
    
    ---
    
    ### Image Widget Boilerplate
    
    Verified against `elementor/includes/widgets/image.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    
    **Critical difference from naive implementations:**
    Elementor uses `Group_Control_Image_Size` for image size — NOT a SELECT with hardcoded sizes.
    This group control handles all registered WordPress sizes plus custom dimensions automatically,
    and must be paired with `Group_Control_Image_Size::get_attachment_image_html()` in render().
    Using `wp_get_attachment_image()` directly bypasses the custom dimension control entirely.
    
    ```php
    protected function register_controls(): void {
    
        // =========================================================
        // TAB: CONTENT
        // =========================================================
    
        $this->start_controls_section( 'section_image', [
            'label' => esc_html__( 'Image', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $this->add_control( 'image', [
            'label'   => esc_html__( 'Choose Image', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::MEDIA,
            'default' => [
                // ✅ Shows Elementor's grey placeholder — keeps widget visible with no image set
                'url' => \Elementor\Utils::get_placeholder_image_src(),
            ],
            'dynamic' => [ 'active' => true ],
        ] );
    
        // ✅ Group_Control_Image_Size generates two controls automatically:
        //    '{name}_size'             → SELECT of all registered WP image sizes + 'custom'
        //    '{name}_custom_dimension' → IMAGE_DIMENSIONS inputs, shown only when size = 'custom'
        // The 'name' value here determines the control ID prefix — must match get_attachment_image_html() call.
        $this->add_group_control(
            \Elementor\Group_Control_Image_Size::get_type(),
            [
                'name'      => 'image',    // generates 'image_size' and 'image_custom_dimension'
                'default'   => 'large',
                'separator' => 'none',
            ]
        );
    
        $this->add_control( 'caption_source', [
            'label'   => esc_html__( 'Caption', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SELECT,
            'default' => 'none',
            'options' => [
                'none'       => esc_html__( 'None',               'myplugin' ),
                'attachment' => esc_html__( 'Attachment Caption', 'myplugin' ),
                'custom'     => esc_html__( 'Custom Caption',     'myplugin' ),
            ],
        ] );
    
        $this->add_control( 'caption', [
            'label'       => esc_html__( 'Custom Caption', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::TEXT,
            'default'     => '',
            'placeholder' => esc_html__( 'Enter your image caption', 'myplugin' ),
            'condition'   => [ 'caption_source' => 'custom' ],
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $this->add_control( 'link_to', [
            'label'   => esc_html__( 'Link', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SELECT,
            'default' => 'none',
            'options' => [
                'none'   => esc_html__( 'None',       'myplugin' ),
                'file'   => esc_html__( 'Media File', 'myplugin' ),
                'custom' => esc_html__( 'Custom URL', 'myplugin' ),
            ],
        ] );
    
        $this->add_control( 'link', [
            'label'       => esc_html__( 'Link URL', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::URL,
            'placeholder' => esc_html__( 'https://your-link.com', 'myplugin' ),
            'condition'   => [ 'link_to' => 'custom' ],
            'dynamic'     => [ 'active' => true ],
            'label_block' => true,
        ] );
    
        // ✅ open_lightbox — control ID matches Elementor native exactly
        $this->add_control( 'open_lightbox', [
            'label'     => esc_html__( 'Lightbox', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SELECT,
            'default'   => 'default',
            'options'   => [
                'default' => esc_html__( 'Default', 'myplugin' ),
                'yes'     => esc_html__( 'Yes',     'myplugin' ),
                'no'      => esc_html__( 'No',      'myplugin' ),
            ],
            'condition' => [ 'link_to' => 'file' ],
        ] );
    
        $this->end_controls_section();
    
        // =========================================================
        // TAB: STYLE
        // =========================================================
    
        $this->start_controls_section( 'section_style_image', [
            'label' => esc_html__( 'Image', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        // ✅ No 'default' — Elementor native leaves this empty (inherits document flow)
        $this->add_responsive_control( 'align', [
            'label'     => esc_html__( 'Alignment', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::CHOOSE,
            'options'   => [
                'left'   => [ 'title' => esc_html__( 'Left',   'myplugin' ), 'icon' => 'eicon-text-align-left'   ],
                'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ],
                'right'  => [ 'title' => esc_html__( 'Right',  'myplugin' ), 'icon' => 'eicon-text-align-right'  ],
            ],
            'selectors' => [
                '{{WRAPPER}}' => 'text-align: {{VALUE}};',
            ],
        ] );
    
        // ✅ Width/Max Width/Height — size intentionally empty (no forced value), unit set only.
        // Empty size = browser/CSS default. Setting a size writes inline style and overrides cascade.
        $this->add_responsive_control( 'width', [
            'label'      => esc_html__( 'Width', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::SLIDER,
            'default'    => [ 'unit' => '%' ],
            'size_units' => [ 'px', '%', 'vw', 'em', 'rem', 'custom' ],
            'range'      => [
                'px' => [ 'min' => 1, 'max' => 1200 ],
                '%'  => [ 'min' => 1, 'max' => 100  ],
                'vw' => [ 'min' => 1, 'max' => 100  ],
            ],
            'selectors'  => [
                // ✅ Elementor native targets '{{WRAPPER}} img' directly — no wrapper class
                '{{WRAPPER}} img' => 'width: {{SIZE}}{{UNIT}};',
            ],
        ] );
    
        $this->add_responsive_control( 'max_width', [
            'label'      => esc_html__( 'Max Width', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::SLIDER,
            'default'    => [ 'unit' => '%' ],
            'size_units' => [ 'px', '%', 'vw', 'em', 'rem', 'custom' ],
            'range'      => [
                'px' => [ 'min' => 1, 'max' => 1200 ],
                '%'  => [ 'min' => 1, 'max' => 100  ],
                'vw' => [ 'min' => 1, 'max' => 100  ],
            ],
            'selectors'  => [
                '{{WRAPPER}} img' => 'max-width: {{SIZE}}{{UNIT}};',
            ],
        ] );
    
        $this->add_responsive_control( 'height', [
            'label'      => esc_html__( 'Height', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::SLIDER,
            'default'    => [ 'unit' => 'px' ],
            'size_units' => [ 'px', 'vh', 'em', 'rem', 'custom' ],
            'range'      => [
                'px' => [ 'min' => 1, 'max' => 1200 ],
                'vh' => [ 'min' => 1, 'max' => 100  ],
            ],
            'selectors'  => [
                '{{WRAPPER}} img' => 'height: {{SIZE}}{{UNIT}}; object-fit: cover;',
            ],
        ] );
    
        // ── Normal / Hover tabs ───────────────────────────────────
        $this->start_controls_tabs( 'tabs_image_effects' );
    
            $this->start_controls_tab( 'tab_image_normal', [
                'label' => esc_html__( 'Normal', 'myplugin' ),
            ] );
    
                // ✅ No default — writing opacity:1 inline blocks CSS hover transitions
                $this->add_control( 'opacity', [
                    'label'     => esc_html__( 'Opacity', 'myplugin' ),
                    'type'      => \Elementor\Controls_Manager::SLIDER,
                    'range'     => [ 'px' => [ 'min' => 0.00, 'max' => 1, 'step' => 0.01 ] ],
                    'selectors' => [
                        '{{WRAPPER}} img' => 'opacity: {{SIZE}};',
                    ],
                ] );
    
                $this->add_group_control(
                    \Elementor\Group_Control_Css_Filter::get_type(),
                    [
                        'name'     => 'css_filters',
                        'selector' => '{{WRAPPER}} img',
                    ]
                );
    
            $this->end_controls_tab();
    
            $this->start_controls_tab( 'tab_image_hover', [
                'label' => esc_html__( 'Hover', 'myplugin' ),
            ] );
    
                $this->add_control( 'opacity_hover', [
                    'label'     => esc_html__( 'Opacity', 'myplugin' ),
                    'type'      => \Elementor\Controls_Manager::SLIDER,
                    'range'     => [ 'px' => [ 'min' => 0.00, 'max' => 1, 'step' => 0.01 ] ],
                    'selectors' => [
                        '{{WRAPPER}}:hover img' => 'opacity: {{SIZE}};',
                    ],
                ] );
    
                $this->add_group_control(
                    \Elementor\Group_Control_Css_Filter::get_type(),
                    [
                        'name'     => 'css_filters_hover',
                        'selector' => '{{WRAPPER}}:hover img',
                    ]
                );
    
                $this->add_control( 'hover_transition', [
                    'label'     => esc_html__( 'Transition Duration', 'myplugin' ),
                    'type'      => \Elementor\Controls_Manager::SLIDER,
                    'range'     => [ 'px' => [ 'min' => 0, 'max' => 3, 'step' => 0.1 ] ],
                    'selectors' => [
                        '{{WRAPPER}} img' => 'transition-duration: {{SIZE}}s;',
                    ],
                ] );
    
                // ⛔ DO NOT include hover_animation by default — loads animation CSS library.
                // Only uncomment if the client explicitly requires image hover animation.
                //
                // $this->add_control( 'hover_animation', [
                //     'label' => esc_html__( 'Hover Animation', 'myplugin' ),
                //     'type'  => \Elementor\Controls_Manager::HOVER_ANIMATION,
                // ] );
    
            $this->end_controls_tab();
    
        $this->end_controls_tabs();
    
        $this->add_group_control(
            \Elementor\Group_Control_Border::get_type(),
            [
                'name'      => 'image_border',
                // ✅ Elementor native targets '{{WRAPPER}} img' directly
                'selector'  => '{{WRAPPER}} img',
                'separator' => 'before',
            ]
        );
    
        $this->add_responsive_control( 'image_border_radius', [
            'label'      => esc_html__( 'Border Radius', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::DIMENSIONS,
            'size_units' => [ 'px', '%', 'em', 'rem' ],
            'selectors'  => [
                '{{WRAPPER}} img' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};',
            ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Box_Shadow::get_type(),
            [
                'name'     => 'image_box_shadow',
                'selector' => '{{WRAPPER}} img',
            ]
        );
    
        $this->end_controls_section();
    }
    ```
    
    **Matching render() skeleton:**
    
    ```php
    
    // ✅ REQUIRED on every widget — removes the redundant inner wrapper div.
    // Return true ONLY if your render() output physically requires the inner wrapper.
    // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ Return false to enable output caching for static widgets (same HTML for all users).
    // Return true if output varies per user, session, time, or random values.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
    
        if ( empty( $settings['image']['url'] ) ) {
            return;
        }
    
        $has_caption = $this->has_caption( $settings );
        $link        = $this->get_link_url( $settings );
    
        $this->add_render_attribute( 'wrapper', 'class', 'elementor-image' );
    
        if ( $link ) {
            $this->add_link_attributes( 'link', $link );
            $this->add_render_attribute( 'link', [
                'class'                        => 'elementor-clickable',
                'data-elementor-open-lightbox' => $settings['open_lightbox'],
            ] );
        }
        ?>
        <div <?php $this->print_render_attribute_string( 'wrapper' ); ?>>
            <?php if ( $has_caption ) : ?><figure class="wp-caption"><?php endif; ?>
            <?php if ( $link ) : ?><a <?php $this->print_render_attribute_string( 'link' ); ?>><?php endif; ?>
    
            <?php
            // ✅ ALWAYS use get_attachment_image_html() — not wp_get_attachment_image().
            // This is the only method that correctly reads both 'image_size' and
            // 'image_custom_dimension' controls generated by Group_Control_Image_Size.
            // Output is WordPress-generated, escaped markup from Elementor's own helper.
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- trusted Elementor-generated <img> markup
            echo \Elementor\Group_Control_Image_Size::get_attachment_image_html( $settings );
            ?>
    
            <?php if ( $link ) : ?></a><?php endif; ?>
            <?php if ( $has_caption ) : ?>
                <figcaption class="widget-image-caption wp-caption-text">
                    <?php echo esc_html( $this->get_caption( $settings ) ); ?>
                </figcaption>
            </figure>
            <?php endif; ?>
        </div>
        <?php
    }
    
    private function has_caption( array $settings ): bool {
        return ( ! empty( $settings['caption_source'] ) && 'none' !== $settings['caption_source'] );
    }
    
    private function get_caption( array $settings ): string {
        if ( 'custom' === $settings['caption_source'] ) {
            return $settings['caption'] ?? '';
        }
        if ( 'attachment' === $settings['caption_source'] && ! empty( $settings['image']['id'] ) ) {
            return (string) wp_get_attachment_caption( (int) $settings['image']['id'] );
        }
        return '';
    }
    
    private function get_link_url( array $settings ): ?array {
        if ( 'none' === $settings['link_to'] ) {
            return null;
        }
        if ( 'custom' === $settings['link_to'] ) {
            return ! empty( $settings['link']['url'] ) ? $settings['link'] : null;
        }
        return [ 'url' => $settings['image']['url'] ]; // 'file'
    }
    
    protected function content_template(): void {
        ?>
        <#
        if ( ! settings.image.url ) { return; }
    
        // ✅ Official Elementor docs use 'const' here — matches developers.elementor.com/docs/widgets/rendering-media/
        const image = {
            id:        settings.image.id,
            url:       settings.image.url,
            size:      settings.image_size,
            dimension: settings.image_custom_dimension,
            model:     view.model, // ✅ view.model replaces deprecated view.getEditModel() (removed Elementor 3.x)
        };
        const image_url = elementor.imagesManager.getImageUrl( image );
        // ✅ Official docs use strict empty string check
        if ( '' === image_url ) { return; }
    
        const hasCaption = 'none' !== settings.caption_source;
        let link_url;
        if ( 'custom' === settings.link_to ) {
            // ✅ Official Elementor pattern — link.url used directly per official advanced example
            link_url = settings.link.url || '';
        }
        if ( 'file' === settings.link_to ) { link_url = settings.image.url; }
    
        // ⛔ hover_animation excluded — uncomment if control is re-enabled:
        // var imgClass = settings.hover_animation ? 'elementor-animation-' + settings.hover_animation : '';
        #>
        <div class="elementor-image">
            <# if ( hasCaption ) { #><figure class="wp-caption"><# } #>
            <# if ( link_url ) { #>
                <a class="elementor-clickable" data-elementor-open-lightbox="{{ settings.open_lightbox }}" href="{{ link_url }}">
            <# } #>
            <img src="{{ image_url }}" alt="">
            <# if ( link_url ) { #></a><# } #>
            <# if ( hasCaption ) { #>
                <figcaption class="widget-image-caption wp-caption-text">
                    {{ 'custom' === settings.caption_source ? settings.caption : '' }}
                </figcaption>
            </figure>
            <# } #>
        </div>
        <?php
    }
    ```
    
    > **Remove checklist:**
    > - `caption_source` + `caption` → remove if image never has a caption
    > - `link_to` + `link` + `open_lightbox` → remove if image is purely decorative
    > - `max_width` → remove if image always fills its container
    > - `height` → remove if image must maintain natural aspect ratio
    > - `image_border_radius` → remove for always-square/unrounded images
    > - `hover_transition` → remove if no CSS hover effect applied to the image
    > - `hover_animation` → **excluded by default** (commented out)
    > - Opacity controls (both tabs) → remove for always-fully-opaque images
    
  • widget-menu-anchor.md 2.3 KB
    # Widget Boilerplate — Menu Anchor
    
    > **When to use this file:** Load whenever building a widget that creates a named anchor for in-page navigation.
    > Verified against `elementor/includes/widgets/menu-anchor.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        $this->start_controls_section( 'section_anchor', [
            'label' => esc_html__( 'Menu Anchor', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $this->add_control( 'anchor', [
            'label'       => esc_html__( 'Menu Anchor ID', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::TEXT,
            'placeholder' => esc_html__( 'For Example: About', 'myplugin' ),
            'description' => esc_html__( 'This ID will be the CSS ID you will have to use without the # sign.', 'myplugin' ),
            'label_block' => true,
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $this->end_controls_section();
    }
    ```
    
    **render() skeleton:**
    
    ```php
    
    // ✅ REQUIRED on every widget — removes the redundant inner wrapper div.
    // Return true ONLY if your render() output physically requires the inner wrapper.
    // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ Return false to enable output caching for static widgets (same HTML for all users).
    // Return true if output varies per user, session, time, or random values.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $id = $this->get_settings_for_display()['anchor'];
        if ( ! empty( $id ) ) {
            echo '<div id="' . esc_attr( sanitize_html_class( $id ) ) . '"></div>';
        }
    }
    ```
    
    ---
    
    
    
    **content_template() skeleton:**
    
    ```php
    protected function content_template(): void {
        ?>
        <# if ( settings.anchor ) {
            // ✅ Strip characters that are invalid in HTML id values (keep a-z A-Z 0-9 _ -)
            // Mirrors PHP-side sanitize_html_class() to keep editor preview consistent with frontend.
            var safeId = settings.anchor.replace( /[^a-zA-Z0-9_-]/g, '' );
        #>
            <div id="{{ safeId }}" class="myplugin-menu-anchor"></div>
        <# } #>
        <?php
    }
    ```
    
  • widget-nested.md 13.4 KB
    # Widget Guide — Nested Tabs & Nested Accordion
    
    > **When to use this file:** Load when building a **Nested** Tabs or Accordion widget — where
    > each panel is a real **Container** the user fills with any elements (not a single
    > rich-text/repeater field). This mirrors Elementor's native **Nested Tabs** (free, 3.10/3.15+)
    > and **Nested Accordion** (free, 3.19+).
    >
    > **Use the classic boilerplates instead** (`widget-tabs.md`, `widget-accordion.md`,
    > `widget-toggle.md`) when each panel only needs simple text/WYSIWYG content. Those are simpler,
    > fully self-contained `Widget_Base` widgets. Reach for **this** guide only when panels must hold
    > arbitrary nested widgets/containers.
    
    ---
    
    ## ⚠️ Read this first — Nested is a different, partly-internal API
    
    Nested widgets do **not** extend `\Elementor\Widget_Base`. They extend
    `\Elementor\Modules\NestedElements\Base\Widget_Nested_Base`, and each "panel" is a child
    **Container** managed by Elementor's Nested Elements editor module. That module's editor-side
    behaviour (keeping the title repeater in sync with the child containers when items are
    added/removed/reordered) is provided by Elementor's own JS handlers — the **third-party-facing
    parts of this API are still only partially documented.**
    
    What this means in practice:
    - The **PHP class structure below is stable and correct** for 3.x–4.x.
    - The **editor add/remove/reorder sync** relies on Elementor's `nested-elements` module. If you
      need fully custom editor behaviour, you are in semi-internal territory — pin your Elementor
      version and test on upgrades.
    - **Requirements:** the **Container** layout is the default in Elementor 4.x, and Nested Elements
      is stable. On older sites both must be enabled under Elementor → Settings → Features.
    
    Source: developers.elementor.com/docs/widgets/ · `elementor/modules/nested-tabs/` ·
    `elementor/modules/nested-accordion/` (Elementor GitHub).
    
    ---
    
    ## Nested Tabs — class skeleton
    
    ```php
    // includes/class-myplugin-nested-tabs.php
    defined( 'ABSPATH' ) || exit;
    
    use Elementor\Controls_Manager;
    use Elementor\Repeater;
    use Elementor\Modules\NestedElements\Base\Widget_Nested_Base;
    use Elementor\Modules\NestedElements\Controls\Control_Nested_Repeater;
    
    class MyPlugin_Nested_Tabs extends Widget_Nested_Base {
    
        public function get_name(): string      { return 'myplugin-nested-tabs'; }
        public function get_title(): string     { return esc_html__( 'My Nested Tabs', 'myplugin' ); }
        public function get_icon(): string      { return 'eicon-tabs'; }
        public function get_categories(): array { return [ 'general' ]; }
    
        // ✅ The default child containers created when the widget is dropped in.
        // Each entry is a 'container' element the user then fills with any widgets.
        protected function get_default_children_elements(): array {
            return [
                [
                    'elType'   => 'container',
                    'settings' => [ '_title' => esc_html__( 'Tab #1', 'myplugin' ) ],
                    'elements' => [],
                ],
                [
                    'elType'   => 'container',
                    'settings' => [ '_title' => esc_html__( 'Tab #2', 'myplugin' ) ],
                    'elements' => [],
                ],
            ];
        }
    
        // ✅ Which repeater control holds each item's title — Elementor uses this to keep the
        // title repeater and the child containers in sync.
        protected function get_default_repeater_title_setting_key(): string {
            return 'tab_title';
        }
    
        // ✅ Title template for newly-added children (%d = index).
        protected function get_default_children_title(): string {
            return esc_html__( 'Tab #%d', 'myplugin' );
        }
    
        // ✅ Selectors the editor uses to place the empty-state "add element" placeholder.
        protected function get_default_children_placeholder_selector(): string {
            return '.myplugin-nested-tabs__content';
        }
    
        protected function get_default_children_container_placeholder_selector(): string {
            return '.e-con';
        }
    
        protected function get_html_wrapper_class(): string {
            return 'myplugin-nested-tabs';
        }
    
        protected function register_controls(): void {
    
            $this->start_controls_section( 'section_tabs', [
                'label' => esc_html__( 'Tabs', 'myplugin' ),
                'tab'   => Controls_Manager::TAB_CONTENT,
            ] );
    
            $repeater = new Repeater();
    
            $repeater->add_control( 'tab_title', [
                'label'       => esc_html__( 'Title', 'myplugin' ),
                'type'        => Controls_Manager::TEXT,
                'default'     => esc_html__( 'Tab Title', 'myplugin' ),
                'placeholder' => esc_html__( 'Tab Title', 'myplugin' ),
                'dynamic'     => [ 'active' => true ],
            ] );
    
            // ✅ Control_Nested_Repeater (NOT the plain REPEATER) — this is the repeater type that
            // syncs each row with a child container. 'tabs' is the items array; prevent_empty stops
            // the user deleting the last tab.
            $this->add_control( 'tabs', [
                'label'         => esc_html__( 'Tabs Items', 'myplugin' ),
                'type'          => Control_Nested_Repeater::CONTROL_TYPE,
                'fields'        => $repeater->get_controls(),
                'default'       => [
                    [ 'tab_title' => esc_html__( 'Tab #1', 'myplugin' ) ],
                    [ 'tab_title' => esc_html__( 'Tab #2', 'myplugin' ) ],
                ],
                'title_field'   => '{{{ tab_title }}}',
                'prevent_empty' => true,
            ] );
    
            $this->end_controls_section();
    
            // Add TAB_STYLE sections for title/active/content colours + typography exactly as in
            // widget-tabs.md — every visual property must still be a control (SKILL.md §5).
        }
    
        public function has_widget_inner_wrapper(): bool {
            return false;
        }
    
        // ✅ Nested content can contain anything (forms, dynamic widgets) — leave caching off
        // unless you are certain every child is static.
        protected function is_dynamic_content(): bool {
            return true;
        }
    
        protected function render(): void {
            $settings = $this->get_settings_for_display();
            $tabs     = $settings['tabs'] ?? [];
            if ( empty( $tabs ) ) {
                return;
            }
    
            // ✅ Per-instance id prefix prevents aria-controls collisions across multiple widgets.
            $id_int = substr( $this->get_id(), 0, 6 );
            ?>
            <div class="myplugin-nested-tabs">
                <div class="myplugin-nested-tabs__headings" role="tablist" aria-orientation="horizontal">
                    <?php foreach ( $tabs as $index => $item ) :
                        $n          = $index + 1;
                        $is_active  = 1 === $n;
                        $title_id   = "myplugin-tab-title-{$id_int}{$n}";
                        $content_id = "myplugin-tab-content-{$id_int}{$n}";
                        ?>
                        <button type="button"
                                id="<?php echo esc_attr( $title_id ); ?>"
                                class="myplugin-nested-tabs__title<?php echo $is_active ? ' myplugin-active' : ''; ?>"
                                role="tab"
                                aria-controls="<?php echo esc_attr( $content_id ); ?>"
                                aria-selected="<?php echo $is_active ? 'true' : 'false'; ?>"
                                tabindex="<?php echo $is_active ? '0' : '-1'; ?>">
                            <?php echo esc_html( $item['tab_title'] ); ?>
                        </button>
                    <?php endforeach; ?>
                </div>
    
                <div class="myplugin-nested-tabs__content">
                    <?php foreach ( $tabs as $index => $item ) :
                        $n          = $index + 1;
                        $is_active  = 1 === $n;
                        $title_id   = "myplugin-tab-title-{$id_int}{$n}";
                        $content_id = "myplugin-tab-content-{$id_int}{$n}";
                        ?>
                        <div id="<?php echo esc_attr( $content_id ); ?>"
                             class="myplugin-nested-tabs__panel<?php echo $is_active ? ' myplugin-active' : ''; ?>"
                             role="tabpanel"
                             aria-labelledby="<?php echo esc_attr( $title_id ); ?>"
                             <?php echo $is_active ? '' : 'hidden'; ?>>
                            <?php
                            // ✅ THE KEY CALL: print the matching child Container by index.
                            // Widget_Nested_Base maps repeater item N to child container N.
                            $this->print_child( $index );
                            ?>
                        </div>
                    <?php endforeach; ?>
                </div>
            </div>
            <?php
        }
    
        // ✅ Editor preview (Backbone). Render the title buttons; child containers are injected by
        // Elementor's nested-elements editor handler, so the panel markup is minimal here.
        protected function content_template(): void {
            ?>
            <div class="myplugin-nested-tabs">
                <div class="myplugin-nested-tabs__headings" role="tablist">
                    <# _.each( settings.tabs, function( item, index ) { var isActive = 0 === index; #>
                        <button type="button"
                                class="myplugin-nested-tabs__title{{ isActive ? ' myplugin-active' : '' }}"
                                role="tab" aria-selected="{{ isActive ? 'true' : 'false' }}"
                                tabindex="{{ isActive ? '0' : '-1' }}">{{ item.tab_title }}</button>
                    <# } ); #>
                </div>
                <div class="myplugin-nested-tabs__content"></div>
            </div>
            <?php
        }
    }
    ```
    
    ---
    
    ## Registration
    
    ```php
    // Nested widgets register through the normal hook.
    add_action( 'elementor/widgets/register', function( \Elementor\Widgets_Manager $manager ) {
        require_once MYPLUGIN_PATH . 'includes/class-myplugin-nested-tabs.php';
        $manager->register( new MyPlugin_Nested_Tabs() );
    } );
    ```
    
    ---
    
    ## Frontend interaction handler (required)
    
    The markup above is static until JS wires up tab switching. Register a frontend handler under
    `elementor/frontend/init` (same pattern as `js-css-standards.md`) — **without `strategy:defer`**:
    
    ```js
    window.addEventListener( 'elementor/frontend/init', () => {
      window.elementorFrontend.hooks.addAction(
        'frontend/element_ready/myplugin-nested-tabs.default',
        ( $scope ) => {
          const root = $scope && $scope[0];
          if ( ! root ) return;
    
          const titles = [ ...root.querySelectorAll( '.myplugin-nested-tabs__title' ) ];
          const panels = [ ...root.querySelectorAll( '.myplugin-nested-tabs__panel' ) ];
    
          const activate = ( i ) => {
            titles.forEach( ( t, n ) => {
              const on = n === i;
              t.classList.toggle( 'myplugin-active', on );
              t.setAttribute( 'aria-selected', String( on ) );
              t.tabIndex = on ? 0 : -1;
            } );
            panels.forEach( ( p, n ) => {
              p.classList.toggle( 'myplugin-active', n === i );
              p.toggleAttribute( 'hidden', n !== i );
            } );
          };
    
          titles.forEach( ( title, i ) => {
            title.addEventListener( 'click', () => activate( i ) );
            // ✅ ARIA APG Tabs keyboard support — arrows move focus, Enter/Space activate.
            title.addEventListener( 'keydown', ( e ) => {
              if ( e.key === 'ArrowRight' || e.key === 'ArrowDown' ) {
                e.preventDefault(); titles[ ( i + 1 ) % titles.length ].focus();
              } else if ( e.key === 'ArrowLeft' || e.key === 'ArrowUp' ) {
                e.preventDefault(); titles[ ( i - 1 + titles.length ) % titles.length ].focus();
              } else if ( e.key === 'Enter' || e.key === ' ' ) {
                e.preventDefault(); activate( i );
              }
            } );
          } );
        }
      );
    } );
    ```
    
    ---
    
    ## Nested Accordion — what changes
    
    Nested Accordion shares the same `Widget_Nested_Base` structure. Differences from Nested Tabs:
    
    1. **Markup:** use the native HTML disclosure pattern — each item is a `<details>`/`<summary>`
       pair (or a `<button aria-expanded>` + region). `<details>`/`<summary>` gives you
       keyboard + screen-reader support **for free**, so prefer it:
       ```php
       foreach ( $items as $index => $item ) : ?>
           <details class="myplugin-nested-accordion__item"<?php echo 0 === $index ? ' open' : ''; ?>>
               <summary class="myplugin-nested-accordion__title"><?php echo esc_html( $item['item_title'] ); ?></summary>
               <div class="myplugin-nested-accordion__panel"><?php $this->print_child( $index ); ?></div>
           </details>
       <?php endforeach;
       ```
    2. **Open behaviour:** Accordion = multiple panels may stay open (like `widget-toggle.md`), or
       add a `max_items_expended = 'one'` control and a small JS handler that closes siblings when one
       `<details>` opens (single-open, like `widget-accordion.md`).
    3. **Icons:** expose `selected_icon` / `selected_active_icon` as **widget-level** `ICONS` controls
       and read them from `$settings`, not the repeater item — see the bug note in
       `widget-toggle.md`.
    4. `get_default_repeater_title_setting_key()` → `'item_title'`; `get_html_wrapper_class()` →
       `'myplugin-nested-accordion'`; icon `eicon-accordion`.
    
    > If you use `<details>`/`<summary>`, you do **not** need the keyboard handler above — the browser
    > provides it. Only add JS for the optional single-open behaviour.
    
    ---
    
    > **Remove / adapt checklist:**
    > - Drop the frontend keyboard handler if you build the Accordion with `<details>`/`<summary>`.
    > - Add TAB_STYLE controls for every colour/spacing/typography value (do not hardcode visuals).
    > - Pin your Elementor version and re-test on upgrade — the nested-elements editor sync is
    >   semi-internal API.
    
  • widget-php-template.md 12.2 KB
    # Widget Pattern — Render a PHP Template File
    
    > **When to use this file:** Load whenever building a widget whose markup lives in a
    > separate `.php` template file rather than being written inline in `render()`.
    > Common use cases: complex card layouts, CPT single views, reusable partials,
    > WooCommerce-style overrideable templates, or any widget needing theme/child-theme
    > template overrides.
    >
    > **Default choice:** If the inner layout should be **visually designed by the user** in
    > Elementor, use `widget-elementor-template.md` instead. This file is for code-defined PHP
    > markup only.
    >
    > **APIs used:**
    > - `load_template()` — WP core, `developer.wordpress.org/reference/functions/load_template/`
    > - `locate_template()` — WP core, `developer.wordpress.org/reference/functions/locate_template/`
    > - `get_template_part()` — WP core, `developer.wordpress.org/reference/functions/get_template_part/`
    
    ---
    
    ## When PHP template files make sense
    
    | Situation | Recommended approach |
    |---|---|
    | User designs the inner layout visually | `widget-elementor-template.md` ✅ |
    | Markup is fixed / developer-defined | **This file** ✅ |
    | Template must be overridable by child themes | **This file** ✅ |
    | Reusing the same markup in shortcodes or REST | **This file** ✅ |
    | Complex conditional markup that would clutter `render()` | **This file** ✅ |
    
    ---
    
    ## File structure
    
    ```
    my-plugin/
    ├── my-plugin.php
    ├── includes/
    │   └── class-myplugin-widget.php
    └── templates/
        └── widget-mywidget.php       ← the PHP template file
    ```
    
    ---
    
    ## Three resolution strategies
    
    Choose the strategy that fits your plugin's architecture:
    
    | Strategy | Template lookup order | Override support | Use when |
    |---|---|---|---|
    | **A — Plugin-only** | `plugin/templates/` only | ❌ No theme override | Templates are internal/non-public |
    | **B — Theme-overrideable** | Child theme → Parent theme → Plugin fallback | ✅ Full override | Distributing a public plugin |
    | **C — `get_template_part()`** | Active theme directory only | ✅ Theme-only | Widget lives inside a theme, not a plugin |
    
    ---
    
    ## Strategy A — Plugin-only template file
    
    ```php
    protected function render(): void {
        $settings = $this->get_settings_for_display();
    
        $template = plugin_dir_path( __FILE__ ) . 'templates/widget-mywidget.php';
    
        // ✅ Verify the file exists before including — avoids fatal errors.
        if ( ! file_exists( $template ) ) {
            return;
        }
    
        // ✅ Pass $settings to the template via $args.
        // Do NOT use extract($settings) — pollutes scope with untrusted keys.
        // The template accesses data as $args['settings']['key'].
        $args = [
            'settings' => $settings,
            'widget'   => $this,
        ];
    
        // ✅ load_template() sets up WP globals ($wp_query, $post, etc.)
        // inside the included file — same environment as standard WP templates.
        // Source: developer.wordpress.org/reference/functions/load_template/
        //
        // Second param $load_once = false — allows the same partial to be included
        // multiple times (e.g. inside a repeater loop). Use true for singletons.
        load_template( $template, false, $args );
    }
    ```
    
    **Template file** (`plugin/templates/widget-mywidget.php`):
    
    ```php
    <?php
    defined( 'ABSPATH' ) || exit;
    
    /**
     * Available variables (passed via load_template $args):
     *
     * @var array  $args['settings']  All widget settings from get_settings_for_display().
     * @var object $args['widget']    The widget instance (\Elementor\Widget_Base).
     *
     * Override: copy to {theme}/myplugin/widget-mywidget.php
     */
    $settings = $args['settings'] ?? [];
    $widget   = $args['widget']   ?? null;
    
    if ( empty( $settings['title'] ) ) {
        return;
    }
    ?>
    <div class="myplugin-widget">
        <h2 class="myplugin-widget__title"><?php echo esc_html( $settings['title'] ); ?></h2>
        <?php if ( ! empty( $settings['description'] ) ) : ?>
            <div class="myplugin-widget__desc"><?php echo wp_kses_post( $settings['description'] ); ?></div>
        <?php endif; ?>
    </div>
    ```
    
    > ⚠️ **Escaping in template files:**
    > Always escape at the point of output inside the template file.
    > `get_settings_for_display()` does **NOT** auto-escape TEXT or TEXTAREA values.
    > Use `esc_html()` for plain text, `wp_kses_post()` for rich text that may contain
    > links or formatting, and `esc_url()` for URLs.
    
    ---
    
    ## Strategy B — Theme-overrideable template (plugin with public API)
    
    This pattern lets child/parent themes override the widget's template by placing a file
    at the same relative path inside their theme directory. Used by WooCommerce, Easy
    Digital Downloads, and similar plugins.
    
    ```php
    protected function render(): void {
        $settings = $this->get_settings_for_display();
    
        // ✅ locate_template() searches:
        //   1. Child theme directory
        //   2. Parent theme directory
        //   3. Returns '' if not found in either
        // Source: developer.wordpress.org/reference/functions/locate_template/
        $theme_template = locate_template( 'myplugin/widget-mywidget.php' );
    
        // Theme override wins; otherwise fall back to the plugin's bundled template.
        $template = $theme_template ?: plugin_dir_path( __FILE__ ) . 'templates/widget-mywidget.php';
    
        if ( ! file_exists( $template ) ) {
            return;
        }
    
        load_template( $template, false, [
            'settings' => $settings,
            'widget'   => $this,
        ] );
    }
    ```
    
    **Applying a filter for full extensibility:**
    
    ```php
    protected function render(): void {
        $settings = $this->get_settings_for_display();
    
        $default_template = plugin_dir_path( __FILE__ ) . 'templates/widget-mywidget.php';
    
        // ✅ Allow plugins/themes to completely replace the template path via filter.
        $template = apply_filters(
            'myplugin_widget_mywidget_template',
            locate_template( 'myplugin/widget-mywidget.php' ) ?: $default_template,
            $settings
        );
    
        if ( ! file_exists( $template ) ) {
            return;
        }
    
        load_template( $template, false, [ 'settings' => $settings, 'widget' => $this ] );
    }
    ```
    
    ---
    
    ## Strategy C — `get_template_part()` (theme-resident widget)
    
    Use this only when the widget lives inside a **theme** (not a plugin). `get_template_part()`
    only searches the active theme and its parent — it has no plugin fallback.
    
    ```php
    protected function render(): void {
        $settings = $this->get_settings_for_display();
    
        // ✅ WP 5.5+ $args parameter passes variables to the template.
        // Inside the template, variables are available as $args['key'].
        // Source: developer.wordpress.org/reference/functions/get_template_part/
        get_template_part(
            'template-parts/widgets/mywidget',  // slug: looks for template-parts/widgets/mywidget.php
            null,                               // name: if set, also looks for mywidget-{name}.php
            [
                'settings' => $settings,
                'widget'   => $this,
            ]
        );
    }
    ```
    
    **Template file** (`theme/template-parts/widgets/mywidget.php`):
    
    ```php
    <?php
    defined( 'ABSPATH' ) || exit;
    
    $settings = $args['settings'] ?? [];
    $widget   = $args['widget']   ?? null;
    ?>
    <div class="myplugin-widget">
        <h2><?php echo esc_html( $settings['title'] ?? '' ); ?></h2>
    </div>
    ```
    
    ---
    
    ## Required widget methods
    
    ```php
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ Return false if the template file renders static content identical
    // for all users — enables Elementor output caching.
    // Return true if the template uses get_the_ID(), is_user_logged_in(),
    // current_user_can(), or any per-user/per-session logic.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return false; // change to true if template uses dynamic/user-specific data
    }
    ```
    
    ---
    
    ## content_template() skeleton
    
    PHP template files cannot run in the JS `content_template()`. Use a representative
    placeholder that mirrors the visual structure so the editor preview matches the frontend:
    
    ```php
    protected function content_template(): void {
        ?>
        <#
        if ( ! settings.title ) { return; }
        #>
        <div class="myplugin-widget">
            <h2 class="myplugin-widget__title">{{ settings.title }}</h2>
            <# if ( settings.description ) { #>
                <div class="myplugin-widget__desc">{{ settings.description }}</div>
            <# } #>
        </div>
        <?php
    }
    ```
    
    > **Note:** If the PHP template contains complex logic that cannot be replicated in JS, it is
    > acceptable for `content_template()` to output a simplified preview. Elementor's own Post
    > Content widget uses this approach. Use `{{ }}` (escaped) for user TEXT values — reserve `{{{ }}}`
    > for Elementor-generated HTML only (see field-notes.md §2). `{{ }}` renders correctly with no
    > apostrophe corruption (the browser decodes the escaped entity on display).
    
    ---
    
    ## Outputting the rendered template as a string (for hooks/filters)
    
    When you need the template output as a string rather than echoing it directly —
    for example to pass it to a filter or cache it:
    
    ```php
    private function get_template_html( array $settings ): string {
        ob_start();
    
        $template = plugin_dir_path( __FILE__ ) . 'templates/widget-mywidget.php';
    
        if ( file_exists( $template ) ) {
            load_template( $template, false, [ 'settings' => $settings, 'widget' => $this ] );
        }
    
        return ob_get_clean() ?: '';
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
        $html     = $this->get_template_html( $settings );
    
        if ( empty( $html ) ) {
            return;
        }
    
        // ✅ The HTML is generated by our own template (already escaped at output) —
        // mark with phpcs ignore so the sniffer does not flag the echo.
        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
        echo $html;
    }
    ```
    
    ---
    
    ## Critical gotchas
    
    **1. Never use `include` or `require` directly**
    `load_template()` sets up all WordPress globals (`$post`, `$wp_query`, `$id`, etc.)
    inside the included file, exactly as WordPress does for standard templates. A bare
    `include` skips this setup, causing WP template tags to fail or return wrong data.
    
    **2. `$args` key — not extracted**
    `load_template()` does NOT extract `$args` into variables. Inside the template,
    all passed values live in `$args['key']`, not `$key` directly. Document this
    in every template file header.
    
    **3. `$load_once = false` for repeated partials**
    If the same template is used in a loop (e.g. a posts grid widget rendering each card),
    pass `false` as the second parameter to `load_template()`. The default `true` uses
    `require_once`, so only the first card would render.
    
    **4. Path traversal — never pass user input as a template name**
    `locate_template()` does not sanitise template names. Never interpolate control values
    directly into template paths. Use a fixed allowlist map instead:
    
    ```php
    // ❌ NEVER do this — path traversal attack vector
    $template = locate_template( $settings['template_name'] . '.php' );
    
    // ✅ Use a fixed allowlist map
    $allowed = [
        'style-a' => 'myplugin/widget-mywidget-a.php',
        'style-b' => 'myplugin/widget-mywidget-b.php',
    ];
    $tpl_slug = $allowed[ $settings['template_style'] ] ?? $allowed['style-a'];
    $template = locate_template( $tpl_slug ) ?: plugin_dir_path( __FILE__ ) . 'templates/' . basename( $tpl_slug );
    ```
    
    **5. Child theme wins over parent theme**
    `locate_template()` checks the child theme first. If a child-theme user adds
    `myplugin/widget-mywidget.php` to their theme, it takes precedence over both the parent
    theme and the plugin fallback.
    
    ---
    
    ## Making templates overridable — developer documentation comment
    
    Add a comment to the top of your plugin's main file so third-party developers know
    where to place overrides:
    
    ```php
    /**
     * Template override:
     * To override widget templates, copy the file from:
     *   my-plugin/templates/widget-mywidget.php
     * to:
     *   {your-theme}/myplugin/widget-mywidget.php
     * Child-theme overrides take precedence over parent-theme overrides.
     */
    ```
    
    ---
    
    > **Remove checklist:**
    > - Strategy B filter (`myplugin_widget_mywidget_template`) → remove for internal plugins.
    > - `ob_start()` wrapper → only needed when returning HTML as a string.
    > - `$args['widget']` → remove if the template does not need widget methods.
    > - `description` control → remove if the widget has only one content field.
    
  • widget-progress.md 6.4 KB
    # Widget Boilerplate — Progress Bar
    
    > **When to use this file:** Load whenever building a widget showing a percentage-based progress bar.
    > Verified against `elementor/includes/widgets/progress.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        $this->start_controls_section( 'section_progress', [
            'label' => esc_html__( 'Progress Bar', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $this->add_control( 'title', [
            'label'       => esc_html__( 'Title', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::TEXT,
            'placeholder' => esc_html__( 'Enter your title', 'myplugin' ),
            'default'     => esc_html__( 'Design', 'myplugin' ),
            'label_block' => true,
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $this->add_control( 'percent', [
            'label'   => esc_html__( 'Percentage', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SLIDER,
            'default' => [ 'size' => 50, 'unit' => '%' ],
            'range'   => [ '%' => [ 'min' => 0, 'max' => 100 ] ],
            'dynamic' => [ 'active' => true ],
        ] );
    
        $this->add_control( 'display_percentage', [
            'label'   => esc_html__( 'Display Percentage', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SWITCHER,
            'default' => 'yes',
        ] );
    
        $this->add_control( 'inner_text', [
            'label'       => esc_html__( 'Inner Text', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::TEXT,
            'placeholder' => esc_html__( '75 of 100 points', 'myplugin' ),
            'default'     => esc_html__( '75 of 100 points', 'myplugin' ),
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE ─────────────────────────────────────────────────
        $this->start_controls_section( 'section_progress_style', [
            'label' => esc_html__( 'Progress Bar', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_control( 'bar_color', [
            'label'     => esc_html__( 'Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'global'    => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_PRIMARY ],
            'selectors' => [ '{{WRAPPER}} .myplugin-bar-fill' => 'background-color: {{VALUE}};' ],
        ] );
    
        $this->add_control( 'bar_bg_color', [
            'label'     => esc_html__( 'Background Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'selectors' => [ '{{WRAPPER}} .myplugin-progress-bar' => 'background-color: {{VALUE}};' ],
        ] );
    
        $this->add_responsive_control( 'bar_height', [
            'label'     => esc_html__( 'Height', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SLIDER,
            'range'     => [ 'px' => [ 'min' => 1, 'max' => 200 ] ],
            'default'   => [ 'size' => 20, 'unit' => 'px' ],
            'selectors' => [ '{{WRAPPER}} .myplugin-progress-bar' => 'height: {{SIZE}}{{UNIT}};' ],
        ] );
    
        $this->add_responsive_control( 'border_radius', [
            'label'      => esc_html__( 'Border Radius', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::DIMENSIONS,
            'size_units' => [ 'px', '%', 'em', 'rem' ],
            'selectors'  => [
                '{{WRAPPER}} .myplugin-progress-bar, {{WRAPPER}} .myplugin-bar-fill' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};',
            ],
        ] );
    
        $this->end_controls_section();
    }
    ```
    
    **render() skeleton:**
    
    ```php
    
    // ✅ REQUIRED on every widget — removes the redundant inner wrapper div.
    // Return true ONLY if your render() output physically requires the inner wrapper.
    // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ Return false to enable output caching for static widgets (same HTML for all users).
    // Return true if output varies per user, session, time, or random values.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
        $pct      = min( 100, (float) ( $settings['percent']['size'] ?? 0 ) );
        ?>
        <div class="myplugin-progress">
            <?php if ( ! empty( $settings['title'] ) ) : ?>
                <div class="myplugin-progress-title">
                    <span><?php echo esc_html( $settings['title'] ); ?></span>
                    <?php if ( 'yes' === $settings['display_percentage'] ) : ?>
                        <span class="myplugin-progress-percentage"><?php echo esc_html( $pct ); ?>%</span>
                    <?php endif; ?>
                </div>
            <?php endif; ?>
            <div class="myplugin-progress-bar" role="progressbar" aria-valuenow="<?php echo esc_attr( $pct ); ?>" aria-valuemin="0" aria-valuemax="100">
                <div class="myplugin-bar-fill" style="width: <?php echo esc_attr( $pct ); ?>%">
                    <?php if ( ! empty( $settings['inner_text'] ) ) : ?>
                        <span class="myplugin-bar-inner-text"><?php echo esc_html( $settings['inner_text'] ); ?></span>
                    <?php endif; ?>
                </div>
            </div>
        </div>
        <?php
    }
    ```
    
    
    **content_template() skeleton:**
    
    ```php
    protected function content_template(): void {
        ?>
        <#
        var pct = Math.min( 100, parseFloat( settings.percent && settings.percent.size || 0 ) );
        #>
        <div class="myplugin-progress">
            <# if ( settings.title ) { #>
            <div class="myplugin-progress-title">
                <span>{{ settings.title }}</span>
                <# if ( 'yes' === settings.display_percentage ) { #>
                    <span class="myplugin-progress-percentage">{{ pct }}%</span>
                <# } #>
            </div>
            <# } #>
            <div class="myplugin-progress-bar" role="progressbar"
                 aria-valuenow="{{ pct }}" aria-valuemin="0" aria-valuemax="100">
                <div class="myplugin-bar-fill" style="width:{{ pct }}%">
                    <# if ( settings.inner_text ) { #>
                        <span class="myplugin-bar-inner-text">{{ settings.inner_text }}</span>
                    <# } #>
                </div>
            </div>
        </div>
        <?php
    }
    ```
    
  • widget-rating.md 8.6 KB
    # Widget Boilerplate — Rating
    
    > **When to use this file:** Load whenever building a structured data / schema-ready rating widget.
    > Verified against `elementor/includes/widgets/rating.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    > ⚠️ This is the newer Rating widget (added ~3.17) — distinct from Star Rating. It supports
    > schema markup and fractional ratings. Use Star Rating for purely decorative displays.
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        // ── CONTENT ───────────────────────────────────────────────
        $this->start_controls_section( 'section_rating', [
            'label' => esc_html__( 'Rating', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $this->add_control( 'rating_type', [
            'label'   => esc_html__( 'Rating Type', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SELECT,
            'default' => 'number',
            'options' => [
                'number'  => esc_html__( 'Number',   'myplugin' ),
                'percent' => esc_html__( 'Percent',  'myplugin' ),
            ],
        ] );
    
        $this->add_control( 'rating', [
            'label'   => esc_html__( 'Rating', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::NUMBER,
            'min'     => 0,
            'max'     => 10,
            'step'    => 0.1,
            'default' => 5,
            'dynamic' => [ 'active' => true ],
        ] );
    
        $this->add_control( 'icon', [
            'label'            => esc_html__( 'Icon', 'myplugin' ),
            'type'             => \Elementor\Controls_Manager::ICONS,
            'fa4compatibility' => 'icon',
            'default'          => [ 'value' => 'fas fa-star', 'library' => 'fa-solid' ],
            'separator'        => 'before',
        ] );
    
        $this->add_control( 'icon_count', [
            'label'   => esc_html__( 'Icon Count', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::NUMBER,
            'min'     => 1,
            'max'     => 10,
            'default' => 5,
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE ─────────────────────────────────────────────────
        $this->start_controls_section( 'section_style_rating', [
            'label' => esc_html__( 'Rating', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_responsive_control( 'align', [
            'label'     => esc_html__( 'Alignment', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::CHOOSE,
            'options'   => [
                'left'   => [ 'title' => esc_html__( 'Left',   'myplugin' ), 'icon' => 'eicon-text-align-left'   ],
                'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ],
                'right'  => [ 'title' => esc_html__( 'Right',  'myplugin' ), 'icon' => 'eicon-text-align-right'  ],
            ],
            'selectors' => [ '{{WRAPPER}}' => 'text-align: {{VALUE}};' ],
        ] );
    
        $this->add_responsive_control( 'icon_size', [
            'label'     => esc_html__( 'Icon Size', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SLIDER,
            'range'     => [ 'px' => [ 'min' => 10, 'max' => 100 ] ],
            'default'   => [ 'size' => 20, 'unit' => 'px' ],
            'selectors' => [
                '{{WRAPPER}} .myplugin-rating-icon i'   => 'font-size: {{SIZE}}{{UNIT}};',
                '{{WRAPPER}} .myplugin-rating-icon svg' => 'width: {{SIZE}}{{UNIT}}; height: {{SIZE}}{{UNIT}};',
            ],
        ] );
    
        $this->add_responsive_control( 'icon_gap', [
            'label'     => esc_html__( 'Icon Gap', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SLIDER,
            'range'     => [ 'px' => [ 'min' => 0, 'max' => 30 ] ],
            'default'   => [ 'size' => 4, 'unit' => 'px' ],
            'selectors' => [
                '{{WRAPPER}} .myplugin-rating-wrapper' => 'gap: {{SIZE}}{{UNIT}};',
            ],
        ] );
    
        $this->add_control( 'icon_color', [
            'label'     => esc_html__( 'Marked Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'default'   => '#f0ad4e',
            'selectors' => [
                '{{WRAPPER}} .myplugin-rating-icon--full i'   => 'color: {{VALUE}};',
                '{{WRAPPER}} .myplugin-rating-icon--full svg' => 'fill: {{VALUE}};',
            ],
            'separator' => 'before',
        ] );
    
        $this->add_control( 'icon_unmarked_color', [
            'label'     => esc_html__( 'Unmarked Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'default'   => '#d0d0d0',
            'selectors' => [
                '{{WRAPPER}} .myplugin-rating-icon--empty i'   => 'color: {{VALUE}};',
                '{{WRAPPER}} .myplugin-rating-icon--empty svg' => 'fill: {{VALUE}};',
            ],
        ] );
    
        $this->end_controls_section();
    }
    ```
    
    **render() + content_template() skeleton:**
    
    ```php
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
    
        $rating     = (float) ( $settings['rating'] ?? 0 );
        $icon_count = (int) ( $settings['icon_count'] ?? 5 );
    
        // Normalise to 0–icon_count range
        if ( 'percent' === $settings['rating_type'] ) {
            $rating = ( $rating / 100 ) * $icon_count;
        } else {
            $rating = min( $rating, 10 ); // max scale is 10
        }
    
        $full_icons  = floor( $rating );
        $partial     = $rating - $full_icons; // fractional part 0–1
        $empty_icons = $icon_count - $full_icons - ( $partial > 0 ? 1 : 0 );
        ?>
        <div class="myplugin-rating"
             role="img"
             aria-label="<?php echo esc_attr( round( $rating, 1 ) . '/' . $icon_count ); ?>">
            <div class="myplugin-rating__wrapper">
                <?php
                // Full icons
                for ( $i = 0; $i < $full_icons; $i++ ) :
                    echo '<span class="myplugin-rating-icon myplugin-rating-icon--full">';
                    \Elementor\Icons_Manager::render_icon( $settings['icon'], [ 'aria-hidden' => 'true' ] );
                    echo '</span>';
                endfor;
    
                // Partial icon — clip span to fractional width.
                // ✅ Required CSS for partial clip to work:
                //    .myplugin-rating-icon--partial { position: relative; overflow: hidden; display: inline-block; }
                //    .myplugin-rating-icon--partial i,
                //    .myplugin-rating-icon--partial svg { position: absolute; left: 0; }
                // Without overflow:hidden the full icon shows even when width is e.g. 30%.
                if ( $partial > 0 ) :
                    echo '<span class="myplugin-rating-icon myplugin-rating-icon--partial" style="width:' . esc_attr( round( $partial * 100 ) ) . '%">';
                    \Elementor\Icons_Manager::render_icon( $settings['icon'], [ 'aria-hidden' => 'true' ] );
                    echo '</span>';
                endif;
    
                // Empty icons
                for ( $i = 0; $i < $empty_icons; $i++ ) :
                    echo '<span class="myplugin-rating-icon myplugin-rating-icon--empty">';
                    \Elementor\Icons_Manager::render_icon( $settings['icon'], [ 'aria-hidden' => 'true' ] );
                    echo '</span>';
                endfor;
                ?>
            </div>
        </div>
        <?php
    }
    
    protected function content_template(): void {
        ?>
        <#
        var rating    = parseFloat( settings.rating ) || 0;
        var iconCount = parseInt( settings.icon_count ) || 5;
        if ( 'percent' === settings.rating_type ) { rating = ( rating / 100 ) * iconCount; }
        else { rating = Math.min( rating, 10 ); }
        var full    = Math.floor( rating );
        var partial = rating - full;
        var empty   = iconCount - full - ( partial > 0 ? 1 : 0 );
        var iconHTML = elementor.helpers.renderIcon( view, settings.icon, { 'aria-hidden': true }, 'i', 'object' );
        var icon = iconHTML && iconHTML.value ? iconHTML.value : '<i class="fas fa-star" aria-hidden="true"></i>'; // ✅ FA fallback, not eicon-star (eicons not reliable on frontend)
        var stars = '';
        for ( var i = 0; i < full; i++ )    { stars += '<span class="myplugin-rating-icon myplugin-rating-icon--full">'    + icon + '</span>'; }
        if ( partial > 0 )                   { stars += '<span class="myplugin-rating-icon myplugin-rating-icon--partial" style="width:' + Math.round(partial*100) + '%">' + icon + '</span>'; }
        for ( var j = 0; j < empty; j++ )   { stars += '<span class="myplugin-rating-icon myplugin-rating-icon--empty">'   + icon + '</span>'; }
        #>
        <div class="myplugin-rating">
            <div class="myplugin-rating__wrapper">{{{ stars }}}</div>
        </div>
        <?php
    }
    ```
    
  • widget-read-more.md 2 KB
    # Widget Boilerplate — Read More
    
    > **When to use this file:** Load whenever building a widget that inserts WordPress's `<!--more-->` tag for archive excerpts.
    > Verified against `elementor/includes/widgets/read-more.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    > This widget has **no controls** — it only renders the WordPress `more` tag.
    
    ---
    
    ```php
    protected function register_controls(): void {
        // No controls — this widget only inserts the WordPress <!--more--> tag
    }
    ```
    
    **render() skeleton:**
    
    ```php
    
    // ✅ REQUIRED on every widget — removes the redundant inner wrapper div.
    // Return true ONLY if your render() output physically requires the inner wrapper.
    // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ MUST be true — this widget mutates the global $more variable which controls how
    // the_content() behaves for the current request. Output caching would freeze the $more=0
    // side-effect at cache-build time and not re-apply it on cached responses, causing full
    // post content to appear instead of truncating at the <!--more--> marker.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return true;
    }
    
    protected function render(): void {
        // ✅ The <!--more--> tag splits post content on ARCHIVE/LOOP pages (index, category, tag).
        // Setting global $more = 0 tells the_content() to stop output at the <!--more--> marker.
        // On singular pages the_content() always shows full content — $more has no effect there.
        // Do NOT add an is_singular() check here: the widget must run on archive pages, which
        // are the pages where <!--more--> is actually respected.
        global $more;
        $more = 0; // phpcs:ignore WordPress.WP.GlobalVariablesOverride
    }
    ```
    
    ---
    
    
    
    **content_template() skeleton:**
    
    ```php
    protected function content_template(): void {
        // Read More has no frontend preview — it only affects archive excerpt splitting.
    }
    ```
    
  • widget-shortcode.md 2.8 KB
    # Widget Boilerplate — Shortcode
    
    > **When to use this file:** Load whenever building a widget that outputs a WordPress shortcode.
    > Verified against `elementor/includes/widgets/shortcode.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        $this->start_controls_section( 'shortcode_section', [
            'label' => esc_html__( 'Shortcode', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $this->add_control( 'shortcode', [
            'label'       => esc_html__( 'Enter your shortcode', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::TEXTAREA,
            'default'     => '',
            'placeholder' => '[shortcode]',
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $this->end_controls_section();
    }
    ```
    
    **render() skeleton:**
    
    ```php
    
    // ✅ REQUIRED on every widget — removes the redundant inner wrapper div.
    // Return true ONLY if your render() output physically requires the inner wrapper.
    // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ MUST be true — do_shortcode() may execute shortcodes that output user-specific
    // content (WooCommerce cart totals, membership gates, nonces, logged-in user data, etc.).
    // Returning false would allow Elementor's output cache to serve one user's shortcode
    // output to a different user. Always return true for shortcode widgets.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return true;
    }
    
    protected function render(): void {
        $settings  = $this->get_settings_for_display();
        $shortcode = do_shortcode( shortcode_unautop( $settings['shortcode'] ) );
        // ✅ wp_kses_post() is intentionally used here — it strips <script> tags and other
        // unsafe markup from shortcode output. Most shortcodes should enqueue scripts via
        // wp_enqueue_script(), not inline them. If a specific shortcode genuinely requires
        // unfiltered output, use the HTML widget instead (which requires unfiltered_html cap).
        echo '<div class="myplugin-shortcode">' . wp_kses_post( $shortcode ) . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- shortcode output is kses-filtered above
    }
    ```
    
    ---
    
    
    
    **content_template() skeleton:**
    
    ```php
    protected function content_template(): void {
        ?>
        <# if ( settings.shortcode ) { #>
            <div class="myplugin-shortcode">
                <p class="myplugin-shortcode-placeholder">
                    <?php echo esc_html__( '[Shortcode placeholder — renders on frontend]', 'myplugin' ); ?>
                    <code>{{ settings.shortcode }}</code>
                </p>
            </div>
        <# } #>
        <?php
    }
    ```
    
  • widget-sidebar.md 2.2 KB
    # Widget Boilerplate — Sidebar
    
    > **When to use this file:** Load whenever building a widget that outputs a registered WordPress sidebar.
    > Verified against `elementor/includes/widgets/sidebar.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        $this->start_controls_section( 'section_sidebar', [
            'label' => esc_html__( 'Sidebar', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        // ✅ Build options list from registered sidebars at registration time
        $sidebars_options = [];
        if ( isset( $GLOBALS['wp_registered_sidebars'] ) ) {
            foreach ( $GLOBALS['wp_registered_sidebars'] as $sidebar_id => $sidebar ) {
                $sidebars_options[ $sidebar_id ] = $sidebar['name'];
            }
        }
    
        $this->add_control( 'sidebar', [
            'label'   => esc_html__( 'Choose Sidebar', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SELECT,
            'default' => array_key_first( $sidebars_options ) ?? '',
            'options' => $sidebars_options,
        ] );
    
        $this->end_controls_section();
    }
    ```
    
    **render() skeleton:**
    
    ```php
    
    // ✅ REQUIRED on every widget — removes the redundant inner wrapper div.
    // Return true ONLY if your render() output physically requires the inner wrapper.
    // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ MUST be true — dynamic_sidebar() outputs registered sidebar widgets which may
    // include user-specific content (login forms, cart widgets, recent posts etc.).
    // Returning false would cache one user's sidebar output and serve it to all visitors.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return true;
    }
    
    protected function render(): void {
        $sidebar = $this->get_settings_for_display()['sidebar'];
        if ( ! empty( $sidebar ) && is_active_sidebar( $sidebar ) ) {
            dynamic_sidebar( $sidebar );
        }
    }
    ```
    
    ---
    
    
    
    **content_template() skeleton:**
    
    ```php
    protected function content_template(): void {
        // Sidebar cannot be previewed in the editor — dynamic PHP output only.
    }
    ```
    
  • widget-social-icons.md 10.1 KB
    # Widget Boilerplate — Social Icons
    
    > **When to use this file:** Load whenever building a widget showing a row of social media icon links.
    > Verified against `elementor/includes/widgets/social-icons.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        // ── CONTENT ───────────────────────────────────────────────
        $this->start_controls_section( 'section_social_icon', [
            'label' => esc_html__( 'Social Icons', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $repeater = new \Elementor\Repeater();
    
        $repeater->add_control( 'social_icon', [
            'label'   => esc_html__( 'Icon', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::ICONS,
            'default' => [ 'value' => 'fab fa-wordpress', 'library' => 'fa-brands' ],
        ] );
    
        $repeater->add_control( 'link', [
            'label'   => esc_html__( 'Link', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::URL,
            'default' => [ 'is_external' => 'true' ],
            'dynamic' => [ 'active' => true ],
        ] );
    
        $repeater->add_control( 'item_icon_color', [
            'label'   => esc_html__( 'Color', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SELECT,
            'default' => 'default',
            'options' => [
                // 'default' = inherit the global Primary/Hover colors from the Style tab.
                // (This boilerplate does not implement per-brand "official" colors — that would
                // require a full brand-color lookup table. Use 'custom' for a per-item color.)
                'default' => esc_html__( 'Default', 'myplugin' ),
                'custom'  => esc_html__( 'Custom',  'myplugin' ),
            ],
        ] );
    
        // ✅ Per-item color, shown only when item_icon_color = 'custom'. The {{CURRENT_ITEM}}
        // token resolves to `.elementor-repeater-item-{id}` — which render() must add to the
        // <a> (see below) for this selector to target the right item.
        $repeater->add_control( 'item_icon_primary_color', [
            'label'     => esc_html__( 'Custom Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'condition' => [ 'item_icon_color' => 'custom' ],
            'selectors' => [
                '{{WRAPPER}} {{CURRENT_ITEM}} i'   => 'color: {{VALUE}};',
                '{{WRAPPER}} {{CURRENT_ITEM}} svg' => 'fill: {{VALUE}};',
            ],
        ] );
    
        $this->add_control( 'social_icon_list', [
            'label'       => esc_html__( 'Social Icons', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::REPEATER,
            'fields'      => $repeater->get_controls(),
            'default'     => [
                [ 'social_icon' => [ 'value' => 'fab fa-facebook', 'library' => 'fa-brands' ], 'link' => [ 'url' => 'https://facebook.com', 'is_external' => 'true' ] ],
                // ✅ Twitter rebranded to X in July 2023. Font Awesome 6.4+ ships 'fa-x-twitter'
                // (fa-brands). Elementor bundles Font Awesome 6.x — fa-x-twitter is available.
                // fa-twitter still renders (FA keeps it as an alias) but fa-x-twitter is correct.
                [ 'social_icon' => [ 'value' => 'fab fa-x-twitter', 'library' => 'fa-brands' ], 'link' => [ 'url' => 'https://x.com', 'is_external' => 'true' ] ],
                [ 'social_icon' => [ 'value' => 'fab fa-instagram','library' => 'fa-brands' ], 'link' => [ 'url' => 'https://instagram.com','is_external' => 'true' ] ],
            ],
            'title_field' => '<# var migrated = "undefined" !== typeof __fa4_migrated, icon = ( migrated || ! icon ) ? social_icon.value : icon; #>{{{ icon }}}',
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE ─────────────────────────────────────────────────
        $this->start_controls_section( 'section_social_style', [
            'label' => esc_html__( 'Icon', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_responsive_control( 'align', [
            'label'     => esc_html__( 'Alignment', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::CHOOSE,
            'options'   => [
                'left'   => [ 'title' => esc_html__( 'Left',   'myplugin' ), 'icon' => 'eicon-text-align-left'   ],
                'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ],
                'right'  => [ 'title' => esc_html__( 'Right',  'myplugin' ), 'icon' => 'eicon-text-align-right'  ],
            ],
            'selectors' => [ '{{WRAPPER}}' => 'text-align: {{VALUE}};' ],
        ] );
    
        $this->add_responsive_control( 'icon_size', [
            'label'     => esc_html__( 'Size', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SLIDER,
            'range'     => [ 'px' => [ 'min' => 6, 'max' => 300 ] ],
            'default'   => [ 'size' => 25, 'unit' => 'px' ],
            'selectors' => [
                '{{WRAPPER}} .myplugin-social-icon' => 'font-size: {{SIZE}}{{UNIT}};',
            ],
        ] );
    
        $this->add_responsive_control( 'icon_spacing', [
            'label'     => esc_html__( 'Spacing', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SLIDER,
            'range'     => [ 'px' => [ 'min' => 0, 'max' => 100 ] ],
            'default'   => [ 'size' => 5, 'unit' => 'px' ],
            'selectors' => [
                '{{WRAPPER}} .myplugin-social-icon:not(:last-child)' => 'margin-right: {{SIZE}}{{UNIT}};',
            ],
        ] );
    
        $this->start_controls_tabs( 'tabs_social_icon_style' );
    
            $this->start_controls_tab( 'tab_social_icon_normal', [ 'label' => esc_html__( 'Normal', 'myplugin' ) ] );
    
                $this->add_control( 'icon_primary_color', [
                    'label'     => esc_html__( 'Primary Color', 'myplugin' ),
                    'type'      => \Elementor\Controls_Manager::COLOR,
                    'selectors' => [
                        '{{WRAPPER}} .myplugin-social-icon i'   => 'color: {{VALUE}};',
                        '{{WRAPPER}} .myplugin-social-icon svg' => 'fill: {{VALUE}};',
                    ],
                ] );
    
            $this->end_controls_tab();
    
            $this->start_controls_tab( 'tab_social_icon_hover', [ 'label' => esc_html__( 'Hover', 'myplugin' ) ] );
    
                $this->add_control( 'hover_primary_color', [
                    'label'     => esc_html__( 'Primary Color', 'myplugin' ),
                    'type'      => \Elementor\Controls_Manager::COLOR,
                    'selectors' => [
                        '{{WRAPPER}} .myplugin-social-icon:hover i'   => 'color: {{VALUE}};',
                        '{{WRAPPER}} .myplugin-social-icon:hover svg' => 'fill: {{VALUE}};',
                    ],
                ] );
    
            $this->end_controls_tab();
    
        $this->end_controls_tabs();
    
        $this->add_responsive_control( 'border_radius', [
            'label'      => esc_html__( 'Border Radius', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::DIMENSIONS,
            'size_units' => [ 'px', '%', 'em', 'rem' ],
            'selectors'  => [
                '{{WRAPPER}} .myplugin-social-icon' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};',
            ],
            'separator'  => 'before',
        ] );
    
        $this->end_controls_section();
    }
    ```
    
    **render() skeleton:**
    
    ```php
    
    // ✅ REQUIRED on every widget — removes the redundant inner wrapper div.
    // Return true ONLY if your render() output physically requires the inner wrapper.
    // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ Return false to enable output caching for static widgets (same HTML for all users).
    // Return true if output varies per user, session, time, or random values.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
        ?>
        <div class="myplugin-social-icons">
            <?php foreach ( $settings['social_icon_list'] as $item ) :
                if ( ! empty( $item['link']['url'] ) ) {
                    $this->add_link_attributes( 'social-icon-' . $item['_id'], $item['link'] );
                }
    
                // ✅ ACCESSIBILITY: the icon is aria-hidden, so without this the link has NO
                // accessible name (WCAG 2.4.4 / 4.1.2 failure — screen readers announce an empty
                // link). Derive a readable label from the icon class (e.g. "fab fa-x-twitter" →
                // "X-twitter"), mirroring how Elementor's native widget adds an .elementor-screen-only
                // label. Hide it visually with a clip utility class (position:absolute; width:1px; etc.).
                $icon_value = $item['social_icon']['value'] ?? '';
                $sr_label   = $icon_value
                    ? ucfirst( preg_replace( '/^.*fa-/', '', $icon_value ) )
                    : esc_html__( 'Social link', 'myplugin' );
                ?>
                <a class="myplugin-social-icon elementor-repeater-item-<?php echo esc_attr( $item['_id'] ); ?>" <?php $this->print_render_attribute_string( 'social-icon-' . $item['_id'] ); ?>>
                    <?php \Elementor\Icons_Manager::render_icon( $item['social_icon'], [ 'aria-hidden' => 'true' ] ); ?>
                    <span class="myplugin-screen-only"><?php echo esc_html( $sr_label ); ?></span>
                </a>
            <?php endforeach; ?>
        </div>
        <?php
    }
    ```
    
    
    **content_template() skeleton:**
    
    ```php
    protected function content_template(): void {
        ?>
        <# if ( ! settings.social_icon_list || ! settings.social_icon_list.length ) { return; } #>
        <div class="myplugin-social-icons">
            <# _.each( settings.social_icon_list, function( item ) {
                var iconHTML = elementor.helpers.renderIcon( view, item.social_icon, { 'aria-hidden': true }, 'i', 'object' );
                // ✅ Official Elementor pattern — link.url used directly per official advanced example
                var url = item.link && item.link.url ? item.link.url : '#';
            #>
            <a class="myplugin-social-icon elementor-repeater-item-{{ item._id }}" href="{{ url }}">{{{ iconHTML.value }}}</a>
            <# } ); #>
        </div>
        <?php
    }
    ```
    
  • widget-spacer.md 1.9 KB
    # Widget Boilerplate — Spacer
    
    > **When to use this file:** Load whenever building a widget that adds vertical space between elements.
    > Verified against `elementor/includes/widgets/spacer.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        $this->start_controls_section( 'section_spacer', [
            'label' => esc_html__( 'Spacer', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $this->add_responsive_control( 'space', [
            'label'      => esc_html__( 'Space', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::SLIDER,
            'default'    => [ 'size' => 50, 'unit' => 'px' ],
            'size_units' => [ 'px', 'em', 'rem', 'vh', '%', 'custom' ],
            'range'      => [ 'px' => [ 'min' => 0, 'max' => 500 ] ],
            'selectors'  => [
                '{{WRAPPER}} .myplugin-spacer' => 'height: {{SIZE}}{{UNIT}};',
            ],
        ] );
    
        $this->end_controls_section();
    }
    ```
    
    **render() skeleton:**
    
    ```php
    
    // ✅ REQUIRED on every widget — removes the redundant inner wrapper div.
    // Return true ONLY if your render() output physically requires the inner wrapper.
    // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ Return false to enable output caching for static widgets (same HTML for all users).
    // Return true if output varies per user, session, time, or random values.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        echo '<div class="myplugin-spacer"></div>';
    }
    
    protected function content_template(): void {
        ?>
        <div class="myplugin-spacer" style="height: {{ settings.space.size }}{{ settings.space.unit }};"></div>
        <?php
    }
    ```
    
  • widget-star-rating.md 9.5 KB
    # Widget Boilerplate — Star Rating
    
    > **When to use this file:** Load whenever building a widget that displays a star (or custom icon) rating.
    > Verified against `elementor/includes/widgets/star-rating.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        $this->start_controls_section( 'section_rating', [
            'label' => esc_html__( 'Rating', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $this->add_control( 'rating_scale', [
            'label'   => esc_html__( 'Rating Scale', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SELECT,
            'options' => [ '5' => '0-5', '10' => '0-10' ],
            'default' => '5',
        ] );
    
        $this->add_control( 'rating', [
            'label'   => esc_html__( 'Rating', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::NUMBER,
            'min'     => 0,
            'max'     => 10,
            'step'    => 0.1,
            'default' => 5,
            'dynamic' => [ 'active' => true ],
        ] );
    
        $this->add_control( 'star_style', [
            'label'        => esc_html__( 'Icon', 'myplugin' ),
            'type'         => \Elementor\Controls_Manager::SELECT,
            'options'      => [
                'star_fontawesome' => esc_html__( 'Font Awesome', 'myplugin' ),
                'star_unicode'     => esc_html__( 'Unicode',      'myplugin' ),
            ],
            'default'      => 'star_fontawesome',
            'prefix_class' => 'elementor--star-style-',
            'separator'    => 'before',
        ] );
    
        $this->add_control( 'unmarked_star_style', [
            'label'   => esc_html__( 'Unmarked Style', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::CHOOSE,
            'options' => [
                'solid'   => [ 'title' => esc_html__( 'Solid',   'myplugin' ), 'icon' => 'eicon-star'          ],
                'outline' => [ 'title' => esc_html__( 'Outline', 'myplugin' ), 'icon' => 'eicon-star-o' ],
            ],
            'default' => 'solid',
        ] );
    
        $this->add_control( 'title', [
            'label'     => esc_html__( 'Title', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::TEXT,
            'separator' => 'before',
            'dynamic'   => [ 'active' => true ],
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE ─────────────────────────────────────────────────
        $this->start_controls_section( 'section_stars_style', [
            'label' => esc_html__( 'Stars', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_responsive_control( 'align', [
            'label'     => esc_html__( 'Alignment', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::CHOOSE,
            'options'   => [
                'left'   => [ 'title' => esc_html__( 'Left',   'myplugin' ), 'icon' => 'eicon-text-align-left'   ],
                'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ],
                'right'  => [ 'title' => esc_html__( 'Right',  'myplugin' ), 'icon' => 'eicon-text-align-right'  ],
            ],
            'selectors' => [ '{{WRAPPER}}' => 'text-align: {{VALUE}};' ],
        ] );
    
        $this->add_responsive_control( 'icon_size', [
            'label'     => esc_html__( 'Icon Size', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SLIDER,
            'range'     => [ 'px' => [ 'min' => 0, 'max' => 100 ] ],
            'default'   => [ 'size' => 20, 'unit' => 'px' ],
            'selectors' => [ '{{WRAPPER}} .myplugin-star-rating' => 'font-size: {{SIZE}}{{UNIT}};' ],
        ] );
    
        $this->add_control( 'star_color', [
            'label'     => esc_html__( 'Marked Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'default'   => '#f0ad4e',
            'selectors' => [ '{{WRAPPER}} .myplugin-star-full i' => 'color: {{VALUE}};' ],
            'separator' => 'before',
        ] );
    
        $this->add_control( 'empty_star_color', [
            'label'     => esc_html__( 'Unmarked Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'default'   => '#d0d0d0',
            'selectors' => [ '{{WRAPPER}} .myplugin-star-empty i' => 'color: {{VALUE}};' ],
        ] );
    
        $this->end_controls_section();
    }
    ```
    
    ---
    
    
    **render() + content_template() skeleton:**
    
    ```php
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
    
        $rating = (float) ( $settings['rating'] ?? 0 );
        $max    = (int)   ( $settings['rating_scale'] ?? 5 );
        $rating = min( $rating, $max );
    
        // ✅ BUG FIX: Do NOT use hardcoded `eicon-star` / `eicon-star-o` class strings here.
        // `eicon-*` are Elementor's EDITOR icon font — the eicons stylesheet is NOT reliably
        // loaded on the frontend (especially when "Inline Font Icons" experiment is active,
        // CSS/JS optimisation is on, or Remove Unused CSS tools prune it). This is a confirmed
        // Elementor core bug (github.com/elementor/elementor/issues/18793).
        //
        // For a custom star rating widget, define ICONS controls for the marked/unmarked star
        // and use Icons_Manager::render_icon() — it loads Font Awesome (or SVG) properly on
        // the frontend. The simplest correct approach: use Font Awesome via the ICONS control.
        //
        // If you MUST use star_style = 'star_fontawesome' with inline <i> tags, use FA classes:
        //   filled  → 'fas fa-star'  (fa-solid)
        //   outline → 'far fa-star'  (fa-regular)
        // NOT eicon-star / eicon-star-o.
        //
        // The cleanest production approach (used below) is to register explicit ICONS controls
        // for the filled and empty stars, then call Icons_Manager::render_icon() for each.
        // Example control additions to register_controls():
        //   $this->add_control( 'icon_marked', [ 'type' => Controls_Manager::ICONS,
        //       'default' => [ 'value' => 'fas fa-star', 'library' => 'fa-solid' ] ] );
        //   $this->add_control( 'icon_empty',  [ 'type' => Controls_Manager::ICONS,
        //       'default' => [ 'value' => 'far fa-star', 'library' => 'fa-regular' ] ] );
    
        $is_unicode = 'star_unicode' === $settings['star_style'];
        ?>
        <div class="myplugin-star-rating" role="img" aria-label="<?php echo esc_attr( $rating . '/' . $max ); ?>">
            <?php if ( ! empty( $settings['title'] ) ) : ?>
                <span class="myplugin-star-rating__title"><?php echo esc_html( $settings['title'] ); ?></span>
            <?php endif; ?>
            <div class="myplugin-star-rating__wrapper">
                <?php for ( $i = 1; $i <= $max; $i++ ) :
                    $is_filled = ( $i <= $rating );
                    $class     = $is_filled ? 'myplugin-star-full' : 'myplugin-star-empty';
                ?>
                    <span class="<?php echo esc_attr( $class ); ?>">
                        <?php if ( $is_unicode ) :
                            // Unicode path — safe, no external font dependency.
                            $is_solid_empty = 'solid' === ( $settings['unmarked_star_style'] ?? 'solid' );
                            $char = $is_filled ? '★' : ( $is_solid_empty ? '★' : '☆' );
                            echo esc_html( $char );
                        else :
                            // Font Awesome path — use Icons_Manager::render_icon() for correct
                            // frontend enqueue. Requires icon_marked / icon_empty ICONS controls.
                            $icon_setting = $is_filled
                                ? ( $settings['icon_marked'] ?? [ 'value' => 'fas fa-star', 'library' => 'fa-solid' ] )
                                : ( $settings['icon_empty']  ?? [ 'value' => 'far fa-star', 'library' => 'fa-regular' ] );
                            \Elementor\Icons_Manager::render_icon( $icon_setting, [ 'aria-hidden' => 'true' ] );
                        endif; ?>
                    </span>
                <?php endfor; ?>
            </div>
        </div>
        <?php
    }
    
    protected function content_template(): void {
        ?>
        <#
        var rating  = parseFloat( settings.rating ) || 0;
        var max     = parseInt( settings.rating_scale ) || 5;
        rating      = Math.min( rating, max );
        var isUnicode = 'star_unicode' === settings.star_style;
        var marked    = 'solid' === settings.unmarked_star_style;
        var stars     = '';
        for ( var i = 1; i <= max; i++ ) {
            var isFilled = ( i <= rating );
            var cls      = isFilled ? 'myplugin-star-full' : 'myplugin-star-empty';
            var icon;
            if ( isUnicode ) {
                icon = isFilled ? '&#9733;' : ( marked ? '&#9733;' : '&#9734;' );
            } else {
                // ✅ Use ICONS control values — renderIcon handles FA properly in the editor.
                // icon_marked / icon_empty are ICONS controls added in register_controls().
                var iconSetting = isFilled
                    ? ( settings.icon_marked || { value: 'fas fa-star',  library: 'fa-solid' } )
                    : ( settings.icon_empty  || { value: 'far fa-star',  library: 'fa-regular' } );
                var iconHTML = elementor.helpers.renderIcon( view, iconSetting, { 'aria-hidden': true }, 'i', 'object' );
                icon = iconHTML && iconHTML.rendered ? iconHTML.value : '<i class="fas fa-star" aria-hidden="true"></i>';
            }
            stars += '<span class="' + cls + '">' + icon + '</span>';
        }
        #>
        <div class="myplugin-star-rating">
            <# if ( settings.title ) { #>
                <span class="myplugin-star-rating__title">{{ settings.title }}</span>
            <# } #>
            <div class="myplugin-star-rating__wrapper">{{{ stars }}}</div>
        </div>
        <?php
    }
    ```
    
  • widget-tabs.md 10.2 KB
    # Widget Boilerplate — Tabs
    
    > **When to use this file:** Load whenever building a widget that shows multiple content panels via clickable tabs.
    > Verified against `elementor/includes/widgets/tabs.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    > ✅ Uses REPEATER control — each tab is one repeater item with title + content.
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        // ── CONTENT ───────────────────────────────────────────────
        $this->start_controls_section( 'section_tabs', [
            'label' => esc_html__( 'Tabs', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $repeater = new \Elementor\Repeater();
    
        $repeater->add_control( 'tab_title', [
            'label'       => esc_html__( 'Title', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::TEXT,
            'default'     => esc_html__( 'Tab Title', 'myplugin' ),
            'placeholder' => esc_html__( 'Tab Title', 'myplugin' ),
            'label_block' => true,
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $repeater->add_control( 'tab_content', [
            'label'      => esc_html__( 'Content', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::WYSIWYG,
            'default'    => esc_html__( 'Tab Content', 'myplugin' ),
            'show_label' => false,
            'dynamic'    => [ 'active' => true ],
        ] );
    
        $this->add_control( 'tabs', [
            'label'       => esc_html__( 'Tabs Items', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::REPEATER,
            'fields'      => $repeater->get_controls(),
            'default'     => [
                [ 'tab_title' => esc_html__( 'Tab #1', 'myplugin' ), 'tab_content' => esc_html__( 'Tab content goes here.', 'myplugin' ) ],
                [ 'tab_title' => esc_html__( 'Tab #2', 'myplugin' ), 'tab_content' => esc_html__( 'Tab content goes here.', 'myplugin' ) ],
                [ 'tab_title' => esc_html__( 'Tab #3', 'myplugin' ), 'tab_content' => esc_html__( 'Tab content goes here.', 'myplugin' ) ],
            ],
            'title_field' => '{{{ tab_title }}}',
        ] );
    
        $this->add_control( 'type', [
            'label'        => esc_html__( 'Type', 'myplugin' ),
            'type'         => \Elementor\Controls_Manager::SELECT,
            'default'      => 'horizontal',
            'options'      => [
                'horizontal' => esc_html__( 'Horizontal', 'myplugin' ),
                'vertical'   => esc_html__( 'Vertical',   'myplugin' ),
            ],
            'prefix_class' => 'elementor-tabs-view-',
            'separator'    => 'before',
        ] );
    
        $this->add_control( 'tab_align_horizontal', [
            'label'        => esc_html__( 'Tabs Alignment', 'myplugin' ),
            'type'         => \Elementor\Controls_Manager::CHOOSE,
            'options'      => [
                'left'    => [ 'title' => esc_html__( 'Left',    'myplugin' ), 'icon' => 'eicon-text-align-left'    ],
                'center'  => [ 'title' => esc_html__( 'Center',  'myplugin' ), 'icon' => 'eicon-text-align-center'  ],
                'right'   => [ 'title' => esc_html__( 'Right',   'myplugin' ), 'icon' => 'eicon-text-align-right'   ],
                'justify' => [ 'title' => esc_html__( 'Justify', 'myplugin' ), 'icon' => 'eicon-text-align-justify' ],
            ],
            'prefix_class' => 'elementor-tabs-alignment-',
            'condition'    => [ 'type' => 'horizontal' ],
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE ─────────────────────────────────────────────────
        $this->start_controls_section( 'section_tabs_style', [
            'label' => esc_html__( 'Tabs', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_control( 'navigation_color', [
            'label'     => esc_html__( 'Text Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'selectors' => [ '{{WRAPPER}} .myplugin-tab-title' => 'color: {{VALUE}};' ],
        ] );
    
        $this->add_control( 'tab_active_color', [
            'label'     => esc_html__( 'Active Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'global'    => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_PRIMARY ],
            'selectors' => [ '{{WRAPPER}} .myplugin-tab-title.myplugin-active' => 'color: {{VALUE}};' ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Typography::get_type(),
            [
                'name'     => 'tab_typography',
                'global'   => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_PRIMARY ],
                'selector' => '{{WRAPPER}} .myplugin-tab-title',
            ]
        );
    
        $this->end_controls_section();
    
        $this->start_controls_section( 'section_content_style', [
            'label' => esc_html__( 'Content', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_control( 'content_color', [
            'label'     => esc_html__( 'Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'global'    => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_TEXT ],
            'selectors' => [ '{{WRAPPER}} .myplugin-tab-content' => 'color: {{VALUE}};' ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Typography::get_type(),
            [
                'name'     => 'content_typography',
                'global'   => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_TEXT ],
                'selector' => '{{WRAPPER}} .myplugin-tab-content',
            ]
        );
    
        $this->end_controls_section();
    }
    ```
    
    **render() skeleton:**
    
    ```php
    
    // ✅ REQUIRED on every widget — removes the redundant inner wrapper div.
    // Return true ONLY if your render() output physically requires the inner wrapper.
    // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ Return false to enable output caching for static widgets (same HTML for all users).
    // Return true if output varies per user, session, time, or random values.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
        $tabs     = $settings['tabs'];
        if ( empty( $tabs ) ) return;
    
        // ✅ CRITICAL: Include widget instance ID in all element IDs.
        // Using only $tab_count (1, 2, 3…) causes ID collisions when multiple Tab widgets
        // appear on the same page — aria-controls points to the WRONG panel in the second widget.
        // substr( $this->get_id(), 0, 3 ) gives a 3-char prefix unique to this widget instance.
        $id_int = substr( $this->get_id(), 0, 3 );
        ?>
        <div class="myplugin-tabs">
            <div class="myplugin-tabs-wrapper" role="tablist">
                <?php foreach ( $tabs as $index => $tab ) :
                    $tab_count = $index + 1;
                    $is_active = 1 === $tab_count;
                ?>
                    <div class="myplugin-tab-title<?php echo $is_active ? ' myplugin-active' : ''; ?>"
                         id="myplugin-tab-title-<?php echo esc_attr( $id_int . $tab_count ); ?>"
                         role="tab"
                         aria-controls="myplugin-tab-content-<?php echo esc_attr( $id_int . $tab_count ); ?>"
                         aria-selected="<?php echo $is_active ? 'true' : 'false'; ?>"
                         tabindex="<?php echo $is_active ? '0' : '-1'; ?>">
                        <?php echo esc_html( $tab['tab_title'] ); ?>
                    </div>
                <?php endforeach; ?>
            </div>
            <div class="myplugin-tabs-content-wrapper">
                <?php foreach ( $tabs as $index => $tab ) :
                    $tab_count = $index + 1;
                    $is_active = 1 === $tab_count;
                ?>
                    <div class="myplugin-tab-content<?php echo $is_active ? ' myplugin-active' : ''; ?>"
                         id="myplugin-tab-content-<?php echo esc_attr( $id_int . $tab_count ); ?>"
                         role="tabpanel"
                         aria-labelledby="myplugin-tab-title-<?php echo esc_attr( $id_int . $tab_count ); ?>">
                        <?php echo wp_kses_post( $tab['tab_content'] ); ?>
                    </div>
                <?php endforeach; ?>
            </div>
        </div>
        <?php
    }
    ```
    
    > ⚠️ **Keyboard interaction requirement (ARIA APG §Tabs):** Elements with `role="tab"` are
    > not native controls, so the markup above is not keyboard-operable on its own. Your widget JS
    > **MUST** handle: `Enter`/`Space` to activate the focused tab, `ArrowLeft`/`ArrowRight` (or
    > Up/Down for vertical) to move focus between tabs, and roving `tabindex` (`0` on the active
    > tab, `-1` on the rest — already emitted by `render()`). Activate the panel on focus or on
    > Enter/Space, and keep `aria-selected` + `tabindex` in sync.
    > Source: w3.org/WAI/ARIA/apg/patterns/tabs/
    
    > **Remove checklist:**
    > - `type` + `tab_align_horizontal` → remove if tabs are always horizontal
    
    
    **content_template() skeleton:**
    
    ```php
    protected function content_template(): void {
        ?>
        <# if ( ! settings.tabs || ! settings.tabs.length ) { return; } #>
        <div class="myplugin-tabs">
            <div class="myplugin-tabs-wrapper" role="tablist">
                <# _.each( settings.tabs, function( tab, index ) {
                    var isActive = 0 === index;
                #>
                <div class="myplugin-tab-title{{ isActive ? ' myplugin-active' : '' }}"
                     role="tab"
                     aria-selected="{{ isActive ? 'true' : 'false' }}"
                     tabindex="{{ isActive ? '0' : '-1' }}">
                    {{ tab.tab_title }}
                </div>
                <# } ); #>
            </div>
            <div class="myplugin-tabs-content-wrapper">
                <# _.each( settings.tabs, function( tab, index ) {
                    var isActive = 0 === index;
                #>
                <div class="myplugin-tab-content{{ isActive ? ' myplugin-active' : '' }}" role="tabpanel">
                    {{{ tab.tab_content }}}
                </div>
                <# } ); #>
            </div>
        </div>
        <?php
    }
    ```
    
  • widget-testimonial.md 11.8 KB
    # Widget Boilerplate — Testimonial
    
    > **When to use this file:** Load whenever building a widget displaying a customer quote with name, job title, and image.
    > Verified against `elementor/includes/widgets/testimonial.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        // ── CONTENT ───────────────────────────────────────────────
        $this->start_controls_section( 'section_testimonial', [
            'label' => esc_html__( 'Testimonial', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $this->add_control( 'testimonial_content', [
            'label'   => esc_html__( 'Content', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::TEXTAREA,
            'rows'    => 10,
            'default' => esc_html__( 'Click edit button to change this text. Lorem ipsum dolor sit amet consectetur adipiscing elit dolor', 'myplugin' ),
            'dynamic' => [ 'active' => true ],
        ] );
    
        $this->add_control( 'testimonial_image', [
            'label'   => esc_html__( 'Choose Image', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::MEDIA,
            'default' => [ 'url' => \Elementor\Utils::get_placeholder_image_src() ],
            'dynamic' => [ 'active' => true ],
        ] );
    
        // ✅ REQUIRED — render() calls get_attachment_image_html( $settings, 'thumbnail', 'testimonial_image' )
        // which reads the 'testimonial_image_size' control generated here.
        $this->add_group_control(
            \Elementor\Group_Control_Image_Size::get_type(),
            [
                'name'      => 'testimonial_image', // generates 'testimonial_image_size' + 'testimonial_image_custom_dimension'
                'default'   => 'thumbnail',
                'separator' => 'none',
            ]
        );
    
        $this->add_control( 'testimonial_name', [
            'label'   => esc_html__( 'Name', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::TEXT,
            'default' => esc_html__( 'John Doe', 'myplugin' ),
            'dynamic' => [ 'active' => true ],
        ] );
    
        $this->add_control( 'testimonial_job', [
            'label'   => esc_html__( 'Title', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::TEXT,
            'default' => esc_html__( 'Designer', 'myplugin' ),
            'dynamic' => [ 'active' => true ],
        ] );
    
        $this->add_control( 'link', [
            'label'     => esc_html__( 'Link', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::URL,
            'dynamic'   => [ 'active' => true ],
            'separator' => 'before',
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE ─────────────────────────────────────────────────
        $this->start_controls_section( 'section_style_testimonial_content', [
            'label' => esc_html__( 'Content', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_responsive_control( 'testimonial_alignment', [
            'label'     => esc_html__( 'Alignment', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::CHOOSE,
            'options'   => [
                'left'   => [ 'title' => esc_html__( 'Left',   'myplugin' ), 'icon' => 'eicon-text-align-left'   ],
                'center' => [ 'title' => esc_html__( 'Center', 'myplugin' ), 'icon' => 'eicon-text-align-center' ],
                'right'  => [ 'title' => esc_html__( 'Right',  'myplugin' ), 'icon' => 'eicon-text-align-right'  ],
            ],
            'selectors' => [ '{{WRAPPER}} .myplugin-testimonial' => 'text-align: {{VALUE}};' ],
        ] );
    
        $this->add_control( 'content_color', [
            'label'     => esc_html__( 'Text Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'global'    => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_TEXT ],
            'selectors' => [ '{{WRAPPER}} .myplugin-testimonial-content' => 'color: {{VALUE}};' ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Typography::get_type(),
            [
                'name'     => 'content_typography',
                'global'   => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_TEXT ],
                'selector' => '{{WRAPPER}} .myplugin-testimonial-content',
            ]
        );
    
        $this->end_controls_section();
    
        $this->start_controls_section( 'section_style_testimonial_image', [
            'label' => esc_html__( 'Image', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_responsive_control( 'image_size', [
            'label'     => esc_html__( 'Size', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SLIDER,
            'range'     => [ 'px' => [ 'min' => 20, 'max' => 200 ] ],
            'default'   => [ 'size' => 60, 'unit' => 'px' ],
            'selectors' => [
                '{{WRAPPER}} .myplugin-testimonial-image img' => 'width: {{SIZE}}{{UNIT}}; height: {{SIZE}}{{UNIT}};',
            ],
        ] );
    
        $this->add_responsive_control( 'image_border_radius', [
            'label'      => esc_html__( 'Border Radius', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::DIMENSIONS,
            'size_units' => [ 'px', '%' ],
            'default'    => [ 'unit' => '%', 'top' => 50, 'right' => 50, 'bottom' => 50, 'left' => 50, 'isLinked' => true ],
            'selectors'  => [
                '{{WRAPPER}} .myplugin-testimonial-image img' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};',
            ],
        ] );
    
        $this->end_controls_section();
    
        $this->start_controls_section( 'section_style_testimonial_name', [
            'label' => esc_html__( 'Name', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_control( 'name_color', [
            'label'     => esc_html__( 'Text Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'global'    => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_PRIMARY ],
            'selectors' => [ '{{WRAPPER}} .myplugin-testimonial-name' => 'color: {{VALUE}};' ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Typography::get_type(),
            [
                'name'     => 'name_typography',
                'global'   => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_PRIMARY ],
                'selector' => '{{WRAPPER}} .myplugin-testimonial-name',
            ]
        );
    
        $this->end_controls_section();
    
        $this->start_controls_section( 'section_style_testimonial_job', [
            'label' => esc_html__( 'Title', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_control( 'job_color', [
            'label'     => esc_html__( 'Text Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'global'    => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_SECONDARY ],
            'selectors' => [ '{{WRAPPER}} .myplugin-testimonial-job' => 'color: {{VALUE}};' ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Typography::get_type(),
            [
                'name'     => 'job_typography',
                'global'   => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_SECONDARY ],
                'selector' => '{{WRAPPER}} .myplugin-testimonial-job',
            ]
        );
    
        $this->end_controls_section();
    }
    ```
    
    **render() skeleton:**
    
    ```php
    
    // ✅ REQUIRED on every widget — removes the redundant inner wrapper div.
    // Return true ONLY if your render() output physically requires the inner wrapper.
    // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ Return false to enable output caching for static widgets (same HTML for all users).
    // Return true if output varies per user, session, time, or random values.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
        ?>
        <div class="myplugin-testimonial">
            <div class="myplugin-testimonial-content">
                <?php echo wp_kses_post( $settings['testimonial_content'] ); ?>
            </div>
            <div class="myplugin-testimonial-meta">
                <?php if ( ! empty( $settings['testimonial_image']['url'] ) ) : ?>
                    <div class="myplugin-testimonial-image">
                        <?php echo wp_kses_post( \Elementor\Group_Control_Image_Size::get_attachment_image_html( $settings, 'thumbnail', 'testimonial_image' ) ); ?>
                    </div>
                <?php endif; ?>
                <div class="myplugin-testimonial-details">
                    <?php if ( ! empty( $settings['testimonial_name'] ) ) :
                        // ✅ Wire up the Link control — wrap the name in an <a> when a URL is set.
                        $name = esc_html( $settings['testimonial_name'] );
                        if ( ! empty( $settings['link']['url'] ) ) {
                            $this->add_link_attributes( 'name_link', $settings['link'] );
                            $name = '<a ' . $this->get_render_attribute_string( 'name_link' ) . '>' . $name . '</a>';
                        }
                        ?>
                        <div class="myplugin-testimonial-name"><?php echo wp_kses_post( $name ); ?></div>
                    <?php endif; ?>
                    <?php if ( ! empty( $settings['testimonial_job'] ) ) : ?>
                        <div class="myplugin-testimonial-job"><?php echo esc_html( $settings['testimonial_job'] ); ?></div>
                    <?php endif; ?>
                </div>
            </div>
        </div>
        <?php
    }
    ```
    
    > **Remove checklist:**
    > - `testimonial_image` → remove if no portrait/avatar needed
    > - `link` → remove if testimonial is not linked
    > - `testimonial_job` → remove if job title is not required
    
    
    **content_template() skeleton:**
    
    ```php
    protected function content_template(): void {
        ?>
        <#
        // ✅ view.model replaces deprecated view.getEditModel() (removed in Elementor 3.x).
        // elementor.imagesManager.getImageUrl() reads image size from the model to pick srcset.
        var image = {
            id:        settings.testimonial_image.id,
            url:       settings.testimonial_image.url,
            size:      settings.testimonial_image_size,
            dimension: settings.testimonial_image_custom_dimension,
            model:     view.model,
        };
        var image_url = elementor.imagesManager.getImageUrl( image );
        #>
        <div class="myplugin-testimonial">
            <div class="myplugin-testimonial-content">{{ settings.testimonial_content }}</div>
            <div class="myplugin-testimonial-meta">
                <# if ( image_url ) { #>
                <div class="myplugin-testimonial-image"><img src="{{ image_url }}" alt=""></div>
                <# } #>
                <div class="myplugin-testimonial-details">
                    <# if ( settings.testimonial_name ) {
                        // ✅ Escape the name before it reaches {{{ nameHtml }}} (triple-brace needed
                        // for the optional <a>) — see field-notes.md §2.
                        var safeName = _.escape( settings.testimonial_name );
                        var nameHtml = settings.link && settings.link.url
                            ? '<a href="' + _.escape( settings.link.url ) + '">' + safeName + '</a>'
                            : safeName;
                    #>
                        <div class="myplugin-testimonial-name">{{{ nameHtml }}}</div>
                    <# } #>
                    <# if ( settings.testimonial_job ) { #>
                        <div class="myplugin-testimonial-job">{{ settings.testimonial_job }}</div>
                    <# } #>
                </div>
            </div>
        </div>
        <?php
    }
    ```
    
  • widget-text-editor.md 4.6 KB
    # Widget Boilerplate — Text Editor
    
    > **When to use this file:** Load whenever building a widget with a rich text / WYSIWYG body content area.
    > Verified against `elementor/includes/widgets/text-editor.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        // ── CONTENT ───────────────────────────────────────────────
        $this->start_controls_section( 'section_editor', [
            'label' => esc_html__( 'Text Editor', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $this->add_control( 'editor', [
            'label'   => '',
            'type'    => \Elementor\Controls_Manager::WYSIWYG,
            'default' => esc_html__( 'Click edit button to change this text. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut elit tellus, luctus nec ullamcorper mattis, pulvinar dapibus leo.', 'myplugin' ),
            'dynamic' => [ 'active' => true ],
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE ─────────────────────────────────────────────────
        $this->start_controls_section( 'section_style', [
            'label' => esc_html__( 'Text Editor', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_responsive_control( 'align', [
            'label'     => esc_html__( 'Alignment', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::CHOOSE,
            'options'   => [
                'left'    => [ 'title' => esc_html__( 'Left',    'myplugin' ), 'icon' => 'eicon-text-align-left'    ],
                'center'  => [ 'title' => esc_html__( 'Center',  'myplugin' ), 'icon' => 'eicon-text-align-center'  ],
                'right'   => [ 'title' => esc_html__( 'Right',   'myplugin' ), 'icon' => 'eicon-text-align-right'   ],
                'justify' => [ 'title' => esc_html__( 'Justify', 'myplugin' ), 'icon' => 'eicon-text-align-justify' ],
            ],
            'selectors' => [ '{{WRAPPER}} .myplugin-text-editor' => 'text-align: {{VALUE}};' ],
        ] );
    
        $this->add_control( 'text_color', [
            'label'     => esc_html__( 'Text Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'default'   => '',
            'selectors' => [
                '{{WRAPPER}} .myplugin-text-editor'    => 'color: {{VALUE}};',
                '{{WRAPPER}} .myplugin-text-editor p'  => 'color: {{VALUE}};',
            ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Typography::get_type(),
            [
                'name'     => 'typography',
                'global'   => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_TEXT ],
                'selector' => '{{WRAPPER}} .myplugin-text-editor',
            ]
        );
    
        $this->add_group_control(
            \Elementor\Group_Control_Text_Shadow::get_type(),
            [
                'name'     => 'text_shadow',
                'selector' => '{{WRAPPER}} .myplugin-text-editor',
            ]
        );
    
        $this->end_controls_section();
    }
    ```
    
    **render() skeleton:**
    
    ```php
    
    // ✅ REQUIRED on every widget — removes the redundant inner wrapper div.
    // Return true ONLY if your render() output physically requires the inner wrapper.
    // Source: developers.elementor.com/docs/widgets/widget-inner-wrapper/
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ Return false to enable output caching for static widgets (same HTML for all users).
    // Return true if output varies per user, session, time, or random values.
    // Source: developers.elementor.com/docs/widgets/widget-output-caching/
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
        $this->add_render_attribute( 'editor', 'class', [ 'myplugin-text-editor', 'elementor-clearfix' ] );
        // ✅ wp_kses_post() — WYSIWYG output must allow HTML tags but still be sanitized.
        // Official Elementor docs omit wp_kses_post() in examples, but our skill's php-standards.md
        // mandates escaping all output. WYSIWYG content is trusted editor HTML, so wp_kses_post is correct.
        echo '<div ' . $this->get_render_attribute_string( 'editor' ) . '>' . wp_kses_post( $settings['editor'] ) . '</div>';
    }
    
    protected function content_template(): void {
        ?>
        <div class="myplugin-text-editor elementor-clearfix">{{{ settings.editor }}}</div>
        <?php
    }
    ```
    
    > **Remove checklist:**
    > - `text_shadow` → remove for flat designs
    
  • widget-text-path.md 13.1 KB
    # Widget Boilerplate — Text Path
    
    > **When to use this file:** Load whenever building a widget that renders text following a
    > curved or custom SVG path (wave, arc, circle, spiral, or a user-supplied SVG).
    > Mirrors Elementor's native **Text Path** widget (free, since ~3.3). Built on V3 `Widget_Base`
    > (Elementor 3.x–4.x; current through 4.2).
    >
    > **How it works:** an inline `<svg>` contains a `<path>` plus a `<text><textPath href="#id">`
    > that flows the text along that path. The path needs a **unique id per widget instance**
    > (derived from `get_id()`) so multiple Text Path widgets on one page don't collide.
    
    ---
    
    ## ⚠️ Two ways to supply the path — security note
    
    | Source | Safe? | Notes |
    |---|---|---|
    | **Preset shapes** (hardcoded `d` attributes below) | ✅ Yes | Recommended default — the geometry is developer-defined, never user input. |
    | **Custom SVG upload** (`MEDIA`, `svg`) | ⚠️ Conditional | SVG is an XSS vector. You MUST sanitize uploaded SVG before inlining it. Elementor ships an SVG sanitizer (`\Elementor\Core\Files\File_Types\Svg`) and only allows SVG uploads when the **"SVG Uploads"** option is enabled. Never `echo` raw uploaded SVG markup. The simplest safe approach is to read only the first `<path d="...">` value and re-emit it through `esc_attr()`. |
    
    This boilerplate uses **presets** as the safe default and shows the custom path as an opt-in.
    
    ---
    
    ## register_controls() skeleton
    
    ```php
    protected function register_controls(): void {
    
        // ── CONTENT ───────────────────────────────────────────────
        $this->start_controls_section( 'section_text_path', [
            'label' => esc_html__( 'Text Path', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $this->add_control( 'text', [
            'label'       => esc_html__( 'Text', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::TEXTAREA,
            'default'     => esc_html__( 'Add Your Curved Text Here', 'myplugin' ),
            'placeholder' => esc_html__( 'Enter your text', 'myplugin' ),
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $this->add_control( 'path_type', [
            'label'   => esc_html__( 'Path', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SELECT,
            'default' => 'wave',
            'options' => [
                'wave'   => esc_html__( 'Wave',   'myplugin' ),
                'arc'    => esc_html__( 'Arc',    'myplugin' ),
                'line'   => esc_html__( 'Line',   'myplugin' ),
                'circle' => esc_html__( 'Circle', 'myplugin' ),
                'custom' => esc_html__( 'Custom SVG', 'myplugin' ),
            ],
        ] );
    
        // Custom SVG — only when path_type = custom (see security note above).
        $this->add_control( 'custom_svg', [
            'label'       => esc_html__( 'Custom SVG', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::MEDIA,
            'media_types' => [ 'svg' ],
            'condition'   => [ 'path_type' => 'custom' ],
            'description' => esc_html__( 'Requires SVG uploads enabled and a sanitized SVG. The first <path> is used.', 'myplugin' ),
        ] );
    
        $this->add_control( 'link', [
            'label'   => esc_html__( 'Link', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::URL,
            'dynamic' => [ 'active' => true ],
        ] );
    
        // ✅ startOffset along the path (0–100%). Set as an SVG attribute in render(), NOT CSS.
        $this->add_control( 'start_point', [
            'label'      => esc_html__( 'Starting Point', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::SLIDER,
            'default'    => [ 'unit' => '%', 'size' => 0 ],
            'size_units' => [ '%' ],
            'range'      => [ '%' => [ 'min' => 0, 'max' => 100 ] ],
        ] );
    
        $this->add_control( 'show_path', [
            'label'   => esc_html__( 'Show Path', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SWITCHER,
            'default' => '',
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE: Text ───────────────────────────────────────────
        $this->start_controls_section( 'section_text_style', [
            'label' => esc_html__( 'Text', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        // ✅ Typography group control — font-family/size/weight/spacing all apply to SVG <text>.
        $this->add_group_control(
            \Elementor\Group_Control_Typography::get_type(),
            [
                'name'     => 'text_typography',
                'selector' => '{{WRAPPER}} .myplugin-text-path text',
            ]
        );
    
        // ✅ SVG text color is the `fill` property — NOT `color`.
        $this->add_control( 'text_color', [
            'label'     => esc_html__( 'Text Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'default'   => '',
            'selectors' => [ '{{WRAPPER}} .myplugin-text-path text' => 'fill: {{VALUE}};' ],
        ] );
    
        $this->add_control( 'text_hover_color', [
            'label'     => esc_html__( 'Hover Text Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'selectors' => [ '{{WRAPPER}} .myplugin-text-path:hover text' => 'fill: {{VALUE}};' ],
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE: Path ───────────────────────────────────────────
        $this->start_controls_section( 'section_path_style', [
            'label'     => esc_html__( 'Path', 'myplugin' ),
            'tab'       => \Elementor\Controls_Manager::TAB_STYLE,
            'condition' => [ 'show_path' => 'yes' ],
        ] );
    
        $this->add_control( 'path_color', [
            'label'     => esc_html__( 'Path Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'default'   => '#d0d0d0',
            'selectors' => [ '{{WRAPPER}} .myplugin-text-path path' => 'stroke: {{VALUE}};' ],
        ] );
    
        $this->add_control( 'path_width', [
            'label'     => esc_html__( 'Path Width', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SLIDER,
            'range'     => [ 'px' => [ 'min' => 0, 'max' => 20 ] ],
            'default'   => [ 'size' => 1, 'unit' => 'px' ],
            'selectors' => [ '{{WRAPPER}} .myplugin-text-path path' => 'stroke-width: {{SIZE}}{{UNIT}};' ],
        ] );
    
        $this->end_controls_section();
    }
    ```
    
    ---
    
    ## Preset path helper
    
    ```php
    /**
     * Developer-defined SVG path presets. Each preset is a [ 'd', 'viewBox' ] pair.
     * Because these `d` strings are hardcoded (never user input), they are safe to output.
     *
     * @return array<string, array{d:string, viewBox:string}>
     */
    private function get_path_presets(): array {
        return [
            'wave'   => [ 'd' => 'M0,50 Q100,0 200,50 T400,50',                       'viewBox' => '0 0 400 100' ],
            'arc'    => [ 'd' => 'M10,90 A190,190 0 0 1 390,90',                      'viewBox' => '0 0 400 110' ],
            'line'   => [ 'd' => 'M0,50 L400,50',                                     'viewBox' => '0 0 400 100' ],
            'circle' => [ 'd' => 'M200,40 a160,160 0 1,1 -0.1,0',                     'viewBox' => '0 0 400 400' ],
        ];
    }
    ```
    
    ---
    
    ## has_widget_inner_wrapper() + is_dynamic_content() + render() skeleton
    
    ```php
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ Static unless the text comes from a per-request Dynamic Tag — false is the safe default.
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
    
        $text = $settings['text'] ?? '';
        if ( '' === $text ) {
            return;
        }
    
        // ✅ Unique path id per instance — prevents <textPath href="#id"> collisions when
        // multiple Text Path widgets share a page. get_id() is the Elementor element id.
        $path_id = 'myplugin-text-path-' . $this->get_id();
    
        $presets = $this->get_path_presets();
        $type    = $settings['path_type'] ?? 'wave';
    
        if ( 'custom' === $type && ! empty( $settings['custom_svg']['url'] ) ) {
            // ⚠️ Custom SVG path. Resolve the uploaded file, sanitize, and extract ONLY the
            // first `d` attribute — never inline the whole uploaded SVG (XSS risk).
            $d       = $this->get_custom_path_d( (int) ( $settings['custom_svg']['id'] ?? 0 ) );
            $view_box = '0 0 400 100'; // fall back; adjust to your SVGs or read it the same way
            if ( '' === $d ) {
                return;
            }
        } else {
            $preset   = $presets[ $type ] ?? $presets['wave'];
            $d        = $preset['d'];
            $view_box = $preset['viewBox'];
        }
    
        // startOffset is an SVG attribute (percentage), not CSS.
        $start_offset = (int) ( $settings['start_point']['size'] ?? 0 );
    
        // Path stroke visibility: 'none' hides it, controls re-enable it via the Path style section.
        $path_fill   = 'none';
        $path_stroke = 'yes' === ( $settings['show_path'] ?? '' ) ? 'currentColor' : 'transparent';
    
        $this->add_render_attribute( 'wrapper', 'class', 'myplugin-text-path' );
    
        $open_link  = '';
        $close_link = '';
        if ( ! empty( $settings['link']['url'] ) ) {
            $this->add_link_attributes( 'link', $settings['link'] );
            $open_link  = '<a ' . $this->get_render_attribute_string( 'link' ) . '>';
            $close_link = '</a>';
        }
        ?>
        <div <?php $this->print_render_attribute_string( 'wrapper' ); ?>>
            <?php echo $open_link; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built via add_link_attributes(), pre-sanitized ?>
            <svg viewBox="<?php echo esc_attr( $view_box ); ?>" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg">
                <path id="<?php echo esc_attr( $path_id ); ?>"
                      d="<?php echo esc_attr( $d ); ?>"
                      fill="<?php echo esc_attr( $path_fill ); ?>"
                      stroke="<?php echo esc_attr( $path_stroke ); ?>"></path>
                <text>
                    <textPath href="#<?php echo esc_attr( $path_id ); ?>"
                              xlink:href="#<?php echo esc_attr( $path_id ); ?>"
                              startOffset="<?php echo esc_attr( $start_offset ); ?>%">
                        <?php echo esc_html( $text ); ?>
                    </textPath>
                </text>
            </svg>
            <?php echo $close_link; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- closing tag literal ?>
        </div>
        <?php
    }
    
    /**
     * Read the first `d` attribute from a sanitized uploaded SVG.
     * Returns '' if the attachment is missing, not an SVG, or contains no <path>.
     */
    private function get_custom_path_d( int $attachment_id ): string {
        if ( $attachment_id <= 0 ) {
            return '';
        }
        $file = get_attached_file( $attachment_id );
        if ( ! $file || ! is_readable( $file ) || 'image/svg+xml' !== get_post_mime_type( $attachment_id ) ) {
            return '';
        }
        $svg = file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
        if ( ! $svg ) {
            return '';
        }
        // ✅ Extract ONLY the first path `d` value — never inline the full SVG.
        if ( preg_match( '/<path[^>]*\sd="([^"]+)"/i', $svg, $m ) ) {
            return $m[1]; // esc_attr() is applied at output in render()
        }
        return '';
    }
    ```
    
    ---
    
    ## content_template() skeleton
    
    ```php
    protected function content_template(): void {
        ?>
        <#
        if ( ! settings.text ) { return; }
        // Preset map mirrored from get_path_presets() — keep both in sync.
        var presets = {
            wave:   { d: 'M0,50 Q100,0 200,50 T400,50', viewBox: '0 0 400 100' },
            arc:    { d: 'M10,90 A190,190 0 0 1 390,90', viewBox: '0 0 400 110' },
            line:   { d: 'M0,50 L400,50',                viewBox: '0 0 400 100' },
            circle: { d: 'M200,40 a160,160 0 1,1 -0.1,0', viewBox: '0 0 400 400' }
        };
        var preset = presets[ settings.path_type ] || presets.wave;
        var pathId = 'myplugin-text-path-preview';
        var offset = settings.start_point && settings.start_point.size ? settings.start_point.size : 0;
        var showPath = 'yes' === settings.show_path;
        #>
        <div class="myplugin-text-path">
            <svg viewBox="{{ preset.viewBox }}" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg">
                <path id="{{ pathId }}" d="{{ preset.d }}" fill="none" stroke="{{ showPath ? 'currentColor' : 'transparent' }}"></path>
                <text><textPath href="#{{ pathId }}" startOffset="{{ offset }}%">{{ settings.text }}</textPath></text>
            </svg>
        </div>
        <?php
    }
    ```
    
    > **Note on the editor preview:** `content_template()` cannot read a custom uploaded SVG's path
    > (that resolution is PHP-side), so the preview always uses a preset shape — the custom path
    > renders on the frontend only. This matches how Elementor's own SVG-dependent widgets behave.
    
    ---
    
    > **Remove checklist:**
    > - `custom_svg` + `get_custom_path_d()` → remove if only preset paths are needed (drops the SVG security surface entirely).
    > - `link` → remove if the curved text is never a link.
    > - Path style section + `show_path` → remove if the path line is always hidden.
    > - `text_hover_color` → remove if there is no hover state.
    
  • widget-toggle.md 12.5 KB
    # Widget Boilerplate — Toggle
    
    > **When to use this file:** Load whenever building a collapsible content widget where multiple panels can be open simultaneously.
    > Verified against `elementor/includes/widgets/toggle.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    > ⚠️ Toggle is structurally identical to Accordion — the only differences are noted below.
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        // ── CONTENT ───────────────────────────────────────────────
        $this->start_controls_section( 'section_toggle', [
            'label' => esc_html__( 'Toggle', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $repeater = new \Elementor\Repeater();
    
        $repeater->add_control( 'tab_title', [
            'label'       => esc_html__( 'Title', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::TEXT,
            'default'     => esc_html__( 'Toggle Title', 'myplugin' ),
            'label_block' => true,
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $repeater->add_control( 'tab_content', [
            'label'      => esc_html__( 'Content', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::WYSIWYG,
            'default'    => esc_html__( 'Toggle Content', 'myplugin' ),
            'show_label' => false,
            'dynamic'    => [ 'active' => true ],
        ] );
    
        $this->add_control( 'tabs', [
            'label'       => esc_html__( 'Toggle Items', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::REPEATER,
            'fields'      => $repeater->get_controls(),
            'default'     => [
                [ 'tab_title' => esc_html__( 'Toggle #1', 'myplugin' ), 'tab_content' => esc_html__( 'Content here.', 'myplugin' ) ],
                [ 'tab_title' => esc_html__( 'Toggle #2', 'myplugin' ), 'tab_content' => esc_html__( 'Content here.', 'myplugin' ) ],
                [ 'tab_title' => esc_html__( 'Toggle #3', 'myplugin' ), 'tab_content' => esc_html__( 'Content here.', 'myplugin' ) ],
            ],
            'title_field' => '{{{ tab_title }}}',
        ] );
    
        $this->add_control( 'selected_icon', [
            'label'            => esc_html__( 'Icon', 'myplugin' ),
            'type'             => \Elementor\Controls_Manager::ICONS,
            'separator'        => 'before',
            'fa4compatibility' => 'icon',
            'default'          => [ 'value' => 'fas fa-plus',  'library' => 'fa-solid' ],
            'label_block'      => false,
            'skin'             => 'inline',
        ] );
    
        $this->add_control( 'selected_active_icon', [
            'label'            => esc_html__( 'Active Icon', 'myplugin' ),
            'type'             => \Elementor\Controls_Manager::ICONS,
            'fa4compatibility' => 'icon_active',
            'default'          => [ 'value' => 'fas fa-minus', 'library' => 'fa-solid' ],
            'label_block'      => false,
            'skin'             => 'inline',
            'condition'        => [ 'selected_icon[value]!' => '' ],
        ] );
    
        $this->add_control( 'title_html_tag', [
            'label'     => esc_html__( 'Title HTML Tag', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SELECT,
            'options'   => [ 'p' => 'p', 'div' => 'div', 'span' => 'span', 'h1' => 'H1', 'h2' => 'H2', 'h3' => 'H3', 'h4' => 'H4', 'h5' => 'H5', 'h6' => 'H6' ],
            'default'   => 'p',
            'separator' => 'before',
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE ─────────────────────────────────────────────────
        $this->start_controls_section( 'section_toggle_style', [
            'label' => esc_html__( 'Toggle', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_control( 'border_width', [
            'label'     => esc_html__( 'Border Width', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SLIDER,
            'range'     => [ 'px' => [ 'min' => 0, 'max' => 10 ] ],
            'default'   => [ 'size' => 1, 'unit' => 'px' ],
            'selectors' => [
                '{{WRAPPER}} .myplugin-toggle-item, {{WRAPPER}} .myplugin-toggle-title, {{WRAPPER}} .myplugin-toggle-content' => 'border-width: {{SIZE}}{{UNIT}};',
            ],
        ] );
    
        $this->add_control( 'border_color', [
            'label'     => esc_html__( 'Border Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'selectors' => [
                '{{WRAPPER}} .myplugin-toggle-item, {{WRAPPER}} .myplugin-toggle-title, {{WRAPPER}} .myplugin-toggle-content' => 'border-color: {{VALUE}};',
            ],
        ] );
    
        $this->end_controls_section();
    
        $this->start_controls_section( 'section_title_style', [
            'label' => esc_html__( 'Title', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_control( 'title_background', [
            'label'     => esc_html__( 'Background', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'selectors' => [ '{{WRAPPER}} .myplugin-toggle-title' => 'background-color: {{VALUE}};' ],
        ] );
    
        $this->add_control( 'title_color', [
            'label'     => esc_html__( 'Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'global'    => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_PRIMARY ],
            'selectors' => [ '{{WRAPPER}} .myplugin-toggle-title' => 'color: {{VALUE}};' ],
        ] );
    
        $this->add_control( 'title_active_color', [
            'label'     => esc_html__( 'Active Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'global'    => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_ACCENT ],
            'selectors' => [ '{{WRAPPER}} .myplugin-toggle-title.myplugin-active' => 'color: {{VALUE}};' ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Typography::get_type(),
            [
                'name'     => 'title_typography',
                'selector' => '{{WRAPPER}} .myplugin-toggle-title',
                'global'   => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_PRIMARY ],
            ]
        );
    
        $this->end_controls_section();
    
        $this->start_controls_section( 'section_content_style', [
            'label' => esc_html__( 'Content', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_control( 'content_background_color', [
            'label'     => esc_html__( 'Background', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'selectors' => [ '{{WRAPPER}} .myplugin-toggle-content' => 'background-color: {{VALUE}};' ],
        ] );
    
        $this->add_control( 'content_color', [
            'label'     => esc_html__( 'Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'global'    => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Colors::COLOR_TEXT ],
            'selectors' => [ '{{WRAPPER}} .myplugin-toggle-content' => 'color: {{VALUE}};' ],
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Typography::get_type(),
            [
                'name'     => 'content_typography',
                'selector' => '{{WRAPPER}} .myplugin-toggle-content',
                'global'   => [ 'default' => \Elementor\Core\Kits\Documents\Tabs\Global_Typography::TYPOGRAPHY_TEXT ],
            ]
        );
    
        $this->end_controls_section();
    }
    ```
    
    > **Key difference from Accordion:**
    > Toggle allows multiple panels open at the same time.
    > In JS, do NOT close other panels when one opens — each item manages its own open/closed state independently.
    
    > ⚠️ **Keyboard interaction requirement:** Elements with `role="button"` on non-native button
    > tags require explicit `keydown` handlers for `Enter` and `Space`. See `widget-accordion.md`
    > for the full keyboard handler pattern — it applies identically to toggle.
    
    > **Remove checklist:**
    > - `selected_icon` + `selected_active_icon` → remove if no expand/collapse icons needed
    > - `title_html_tag` → remove if title tag is always fixed
    
    **render() + content_template() skeleton:**
    
    ```php
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
        $tabs     = $settings['tabs'];
    
        if ( empty( $tabs ) ) {
            return;
        }
    
        // ✅ get_id() replaces deprecated get_id_int() (deprecated since Elementor 3.1.0)
        // Source: developers.elementor.com/v3-5-planned-deprecations/
        $id_int = substr( $this->get_id(), 0, 3 );
        ?>
        <div class="myplugin-toggle">
            <?php foreach ( $tabs as $index => $tab ) :
                $tab_count = $index + 1;
                $tab_id    = 'myplugin-toggle-' . $id_int . $tab_count;
                $tag       = \Elementor\Utils::validate_html_tag( $settings['title_html_tag'] );
            ?>
            <?php $content_id = 'myplugin-toggle-content-' . $id_int . $tab_count; ?>
            <div class="myplugin-toggle-item elementor-repeater-item-<?php echo esc_attr( $tab['_id'] ); ?>">
                <<?php echo esc_attr( $tag ); ?>
                    id="<?php echo esc_attr( $tab_id ); ?>"
                    class="myplugin-toggle-title"
                    aria-expanded="false"
                    aria-controls="<?php echo esc_attr( $content_id ); ?>"
                    role="button"
                    tabindex="0">
                    <?php
                    // ✅ BUG FIX: selected_icon / selected_active_icon are WIDGET-LEVEL controls
                    // (added via $this->add_control), not per-item repeater controls. Check
                    // $settings, not $tab — $tab['selected_icon'] is always empty, which would
                    // make the icon never render. (Same fix as widget-accordion.md.)
                    if ( ! empty( $settings['selected_icon']['value'] ) ) : ?>
                        <span class="myplugin-toggle-icon">
                            <span class="myplugin-toggle-icon-opened">
                                <?php \Elementor\Icons_Manager::render_icon( $settings['selected_active_icon'], [ 'aria-hidden' => 'true' ] ); ?>
                            </span>
                            <span class="myplugin-toggle-icon-closed">
                                <?php \Elementor\Icons_Manager::render_icon( $settings['selected_icon'], [ 'aria-hidden' => 'true' ] ); ?>
                            </span>
                        </span>
                    <?php endif; ?>
                    <span class="myplugin-toggle-title-text"><?php echo esc_html( $tab['tab_title'] ); ?></span>
                </<?php echo esc_attr( $tag ); ?>>
                <div class="myplugin-toggle-content"
                     id="<?php echo esc_attr( $content_id ); ?>"
                     role="region"
                     aria-labelledby="<?php echo esc_attr( $tab_id ); ?>"
                     style="display:none;">
                    <?php echo wp_kses_post( $tab['tab_content'] ); ?>
                </div>
            </div>
            <?php endforeach; ?>
        </div>
        <?php
    }
    
    protected function content_template(): void {
        ?>
        <#
        if ( ! settings.tabs || ! settings.tabs.length ) { return; }
        var tag = elementor.helpers.validateHTMLTag( settings.title_html_tag );
        #>
        <div class="myplugin-toggle">
            <# _.each( settings.tabs, function( tab, index ) {
                // ✅ BUG FIX: selected_icon is a WIDGET-LEVEL control — read settings.*, not tab.*.
                var iconHTML = settings.selected_icon && settings.selected_icon.value
                    ? elementor.helpers.renderIcon( view, settings.selected_icon, { 'aria-hidden': true }, 'i', 'object' )
                    : null;
            #>
            <# var contentId = 'myplugin-toggle-content-' + index; #>
            <div class="myplugin-toggle-item">
                <{{{ tag }}} class="myplugin-toggle-title" aria-expanded="false" aria-controls="{{ contentId }}" role="button" tabindex="0">
                    <# if ( iconHTML ) { #>
                        <span class="myplugin-toggle-icon">
                            <span class="myplugin-toggle-icon-opened">{{{ elementor.helpers.renderIcon( view, settings.selected_active_icon, { 'aria-hidden': true }, 'i', 'object' ).value }}}</span>
                            <span class="myplugin-toggle-icon-closed">{{{ iconHTML.value }}}</span>
                        </span>
                    <# } #>
                    <span class="myplugin-toggle-title-text">{{ tab.tab_title }}</span>
                </{{{ tag }}}>
                <div class="myplugin-toggle-content" style="display:none;">
                    {{{ tab.tab_content }}}
                </div>
            </div>
            <# } ); #>
        </div>
        <?php
    }
    ```
    
  • widget-video.md 10.4 KB
    # Widget Boilerplate — Video
    
    > **When to use this file:** Load whenever building a widget that embeds a video (YouTube, Vimeo, self-hosted).
    > Verified against `elementor/includes/widgets/video.php` (Elementor 3.35+ / V3 Widget_Base API, current through 4.2).
    
    ---
    
    ```php
    protected function register_controls(): void {
    
        // ── CONTENT ───────────────────────────────────────────────
        $this->start_controls_section( 'section_video', [
            'label' => esc_html__( 'Video', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ] );
    
        $this->add_control( 'video_type', [
            'label'   => esc_html__( 'Source', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SELECT,
            'default' => 'youtube',
            'options' => [
                'youtube'     => esc_html__( 'YouTube',      'myplugin' ),
                'vimeo'       => esc_html__( 'Vimeo',        'myplugin' ),
                'dailymotion' => esc_html__( 'Dailymotion',  'myplugin' ),
                'hosted'      => esc_html__( 'Self Hosted',  'myplugin' ),
            ],
        ] );
    
        $this->add_control( 'youtube_url', [
            'label'       => esc_html__( 'Link', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::TEXT,
            'default'     => 'https://www.youtube.com/watch?v=XHOmBV4js_E',
            'placeholder' => esc_html__( 'Enter your YouTube URL', 'myplugin' ),
            'label_block' => true,
            'condition'   => [ 'video_type' => 'youtube' ],
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $this->add_control( 'vimeo_url', [
            'label'       => esc_html__( 'Link', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::TEXT,
            'default'     => 'https://vimeo.com/235215203',
            'placeholder' => esc_html__( 'Enter your Vimeo URL', 'myplugin' ),
            'label_block' => true,
            'condition'   => [ 'video_type' => 'vimeo' ],
            'dynamic'     => [ 'active' => true ],
        ] );
    
        $this->add_control( 'hosted_url', [
            'label'      => esc_html__( 'Choose File', 'myplugin' ),
            'type'       => \Elementor\Controls_Manager::MEDIA,
            'media_type' => 'video',
            'condition'  => [ 'video_type' => 'hosted' ],
            'dynamic'    => [ 'active' => true ],
        ] );
    
        $this->add_control( 'start', [
            'label'       => esc_html__( 'Start Time', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::NUMBER,
            'description' => esc_html__( 'Specify a start time (in seconds)', 'myplugin' ),
            'condition'   => [ 'video_type' => [ 'youtube', 'hosted' ] ],
        ] );
    
        $this->add_control( 'end', [
            'label'       => esc_html__( 'End Time', 'myplugin' ),
            'type'        => \Elementor\Controls_Manager::NUMBER,
            'description' => esc_html__( 'Specify an end time (in seconds)', 'myplugin' ),
            'condition'   => [ 'video_type' => [ 'youtube', 'hosted' ] ],
        ] );
    
        $this->add_control( 'video_options', [
            'label'     => esc_html__( 'Video Options', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::HEADING,
            'separator' => 'before',
        ] );
    
        $this->add_control( 'autoplay', [
            'label'   => esc_html__( 'Autoplay', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SWITCHER,
            'default' => '',
        ] );
    
        $this->add_control( 'mute', [
            'label'   => esc_html__( 'Mute', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SWITCHER,
            'default' => '',
        ] );
    
        $this->add_control( 'loop', [
            'label'   => esc_html__( 'Loop', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SWITCHER,
            'default' => '',
        ] );
    
        $this->add_control( 'controls', [
            'label'   => esc_html__( 'Player Controls', 'myplugin' ),
            'type'    => \Elementor\Controls_Manager::SWITCHER,
            'default' => 'yes',
        ] );
    
        $this->add_control( 'show_image_overlay', [
            'label'     => esc_html__( 'Privacy Mode', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SWITCHER,
            'default'   => '',
            'separator' => 'before',
        ] );
    
        $this->add_control( 'image_overlay', [
            'label'     => esc_html__( 'Choose Image', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::MEDIA,
            'default'   => [ 'url' => \Elementor\Utils::get_placeholder_image_src() ],
            'condition' => [ 'show_image_overlay' => 'yes' ],
            'dynamic'   => [ 'active' => true ],
        ] );
    
        $this->add_control( 'show_play_icon', [
            'label'     => esc_html__( 'Play Icon', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SWITCHER,
            'default'   => 'yes',
            'condition' => [ 'show_image_overlay' => 'yes' ],
        ] );
    
        $this->add_control( 'lightbox', [
            'label'     => esc_html__( 'Lightbox', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::SWITCHER,
            'default'   => '',
            'condition' => [ 'show_image_overlay' => 'yes' ],
        ] );
    
        $this->end_controls_section();
    
        // ── STYLE ─────────────────────────────────────────────────
        $this->start_controls_section( 'section_video_style', [
            'label' => esc_html__( 'Video', 'myplugin' ),
            'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
        ] );
    
        $this->add_control( 'aspect_ratio', [
            'label'        => esc_html__( 'Aspect Ratio', 'myplugin' ),
            'type'         => \Elementor\Controls_Manager::SELECT,
            'options'      => [
                '169' => '16:9',
                '219' => '21:9',
                '43'  => '4:3',
                '32'  => '3:2',
                '11'  => '1:1',
                '916' => '9:16',
            ],
            'default'      => '169',
            // ✅ prefix_class ONLY — writes e.g. 'elementor-aspect-ratio-169' on the wrapper.
            // Elementor's own CSS uses this class with padding-top trick or aspect-ratio property.
            // DO NOT add a 'selectors' entry here — '{{VALUE}}' would output '169' which is invalid CSS.
            'prefix_class' => 'elementor-aspect-ratio-',
        ] );
    
        $this->add_group_control(
            \Elementor\Group_Control_Css_Filter::get_type(),
            [
                'name'     => 'css_filters',
                'selector' => '{{WRAPPER}} .elementor-wrapper',
            ]
        );
    
        $this->add_control( 'play_icon_color', [
            'label'     => esc_html__( 'Play Icon Color', 'myplugin' ),
            'type'      => \Elementor\Controls_Manager::COLOR,
            'selectors' => [ '{{WRAPPER}} .elementor-custom-embed-play i' => 'color: {{VALUE}};' ],
            'condition' => [ 'show_image_overlay' => 'yes', 'show_play_icon' => 'yes' ],
        ] );
    
        $this->end_controls_section();
    }
    ```
    
    > **Remove checklist:**
    > - `start` + `end` → remove if no time-scrubbing needed
    > - `show_image_overlay` + `image_overlay` + `show_play_icon` + `lightbox` → remove if no overlay/privacy mode
    > - `css_filters` → remove for simple embeds
    
    **render() + content_template() skeleton:**
    
    ```php
    public function has_widget_inner_wrapper(): bool {
        return false;
    }
    
    // ✅ Video embeds change based on URL — not user-specific, so static caching is safe.
    protected function is_dynamic_content(): bool {
        return false;
    }
    
    protected function render(): void {
        $settings = $this->get_settings_for_display();
    
        $video_url = '';
        switch ( $settings['video_type'] ) {
            case 'youtube':
                $video_url = $settings['youtube_url'];
                break;
            case 'vimeo':
                $video_url = $settings['vimeo_url'];
                break;
            case 'hosted':
                $video_url = ! empty( $settings['hosted_url']['url'] ) ? $settings['hosted_url']['url'] : '';
                break;
        }
    
        if ( empty( $video_url ) ) {
            return;
        }
    
        // ✅ Use wp_oembed_get() for YouTube/Vimeo — handles oEmbed parameters correctly.
        // For self-hosted, output an HTML5 <video> tag directly.
        if ( 'hosted' === $settings['video_type'] ) {
            $has_autoplay = 'yes' === $settings['autoplay'];
            $has_muted    = 'yes' === $settings['mute'];
    
            // ✅ Browser Autoplay Policy: autoplay without muted is blocked in Chrome/Safari/Firefox.
            // Enforce muted when autoplay is requested to guarantee playback actually starts.
            // Do NOT silently strip autoplay — show it as requested but force muted alongside it.
            if ( $has_autoplay && ! $has_muted ) {
                $has_muted = true; // force muted so autoplay is honoured
            }
    
            // ✅ Build attributes array then implode — avoids raw HTML injection via esc_attr
            // on space-joined strings. Each boolean attribute is a plain ASCII word, safe to output.
            $attrs = array_filter( [
                'yes' === $settings['controls'] ? 'controls' : '',
                $has_autoplay ? 'autoplay' : '',
                $has_muted    ? 'muted'    : '',
                'yes' === $settings['loop'] ? 'loop' : '',
                'playsinline', // always — prevents iOS Safari fullscreen takeover
            ] );
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $attrs are all hardcoded ASCII attribute name strings; $video_url escaped via esc_url below
            echo sprintf(
                '<div class="myplugin-video elementor-wrapper"><video %s src="%s"></video></div>',
                implode( ' ', $attrs ),
                esc_url( $video_url )
            );
        } else {
            $oembed = wp_oembed_get( $video_url );
            if ( $oembed ) {
                // ✅ wp_kses_post() allows <iframe> (needed for YouTube/Vimeo oEmbed output).
                // Do NOT use esc_html() here — that would entity-encode the entire iframe tag.
                echo '<div class="myplugin-video elementor-wrapper">' . wp_kses_post( $oembed ) . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- kses-filtered above
            }
        }
    }
    
    protected function content_template(): void {
        ?>
        <#
        var url = '';
        if ( 'youtube' === settings.video_type ) { url = settings.youtube_url; }
        else if ( 'vimeo' === settings.video_type ) { url = settings.vimeo_url; }
        else if ( 'hosted' === settings.video_type && settings.hosted_url ) { url = settings.hosted_url.url; }
        if ( ! url ) { return; }
        #>
        <div class="myplugin-video elementor-wrapper">
            <div class="myplugin-video-placeholder">
                <i class="eicon-video-camera" aria-hidden="true"></i>
            </div>
        </div>
        <?php
    }
    ```
    
  • woocommerce.md 8.5 KB
    # WooCommerce Integration
    
    ## ⚠️ HPOS Compatibility — Required for All WooCommerce Plugins (WC 8.2+)
    
    WooCommerce High-Performance Order Storage (HPOS) uses dedicated order tables instead
    of `wp_posts`/`wp_postmeta`. It is **enabled by default for all new stores since WC 8.2
    (October 2023)**. Any plugin that declares `WC tested up to` in its header and touches
    orders MUST declare HPOS compatibility or WooCommerce will block the feature with admin
    warnings.
    
    > ⚠️ **WooCommerce 10.7 (released April 14, 2026) — HPOS "Sync on Read" disabled by default:**
    > As of WooCommerce 10.7, the HPOS **"sync on read"** mechanism is disabled by default
    > (current stable is **11.0.1**, released Aug 10, 2026).
    > **What this means:** "Sync on read" was a safety net that detected when order data was written
    > directly to the legacy `wp_posts`/`wp_postmeta` tables (bypassing WooCommerce's API) and
    > pulled those changes back into HPOS on the next read. With this disabled, any plugin or custom
    > code that writes order data via `wp_update_post()`, `update_post_meta()`, or direct SQL will
    > **silently produce stale data** in HPOS.
    >
    > **Action required:** Audit all code that touches order data. Migrate to HPOS-native API.
    > ```php
    > // Reproduce the 10.7+ default (sync-on-read disabled) on an older WooCommerce to test:
    > add_filter( 'woocommerce_hpos_enable_sync_on_read', '__return_false' );
    >
    > // Temporarily re-enable sync-on-read on 10.7+ (bridge only, while you migrate):
    > add_filter( 'woocommerce_hpos_enable_sync_on_read', '__return_true' );
    > ```
    > Re-enabling should be treated as a temporary measure only — sync-on-read may be fully
    > removed in a future WooCommerce version.
    > Filter source: `woocommerce/src/Internal/DataStores/Orders/DataSynchronizer.php` —
    > `apply_filters( 'woocommerce_hpos_enable_sync_on_read', ... )` (WooCommerce GitHub, `trunk` branch).
    > Feature announcement: developer.woocommerce.com/2026/02/16/hpos-sync-on-read-to-be-disabled-by-default-in-woocommerce-10-7/
    
    ```php
    // HPOS compatibility declaration — add to your main plugin file (see scaffolding.md)
    add_action( 'before_woocommerce_init', function() {
        if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
            \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility(
                'custom_order_tables',
                __FILE__,
                true
            );
        }
    } );
    ```
    
    > 📌 **WooCommerce 10.9 (released June 23, 2026) — developer-facing changes** (no HPOS or
    > minimum-requirement changes; the HPOS guidance above stands):
    > - **Store API defers draft-order creation.** A persisted draft order is no longer created
    >   early in a fresh checkout session (GET/PATCH); it is created **close to place-order time**
    >   (fewer orphaned `checkout-draft` rows). Any code that assumes a draft order row exists as
    >   soon as a checkout session starts — meta writes, hooks keyed on the draft order, analytics
    >   on draft rows — must handle the order appearing late.
    > - **Product editor beta enters its FINAL deprecation window** — ✅ **removed in WC 11.0, see
    >   below.** Don't build against it; target the classic product screen.
    > - **Abilities/MCP:** rebuilt canonical domain abilities for product & order operations
    >   (query/create/update products, manage orders) on the WordPress **Abilities API** — see
    >   `SKILL.md` (WP 7.0 section) for the `wp_abilities_api_init` registration rules.
    > - **Experimental:** a dual code-API (PHP classes) + autogenerated GraphQL mirror. Experimental
    >   — don't ship production code against it yet.
    > - **Transactional email logging in core** (WooCommerce → Status → Logs) — no separate
    >   logging plugin needed when debugging mail.
    > Source: developer.woocommerce.com/2026/06/23/woocommerce-10-9/
    
    > 🔴 **WooCommerce 11.0 (released Aug 4, 2026; current 11.0.1, Aug 10) — two breaking changes.**
    > HPOS behaviour and the Order API above are **unchanged**; these are the items that break code:
    >
    > **1. The product editor beta is GONE.** The removal is complete, not deprecated: the
    > `@woocommerce/product-editor` package, the block-based product screens in WooCommerce Admin,
    > the feature flag/toggle, the editor-specific routes and menu entries, **and their extension
    > points**. The **classic product editor is now the only product editing experience in core.**
    > - **Product data is untouched** — no migration, no data loss; existing products edit normally.
    > - **What breaks:** any extension that registered a block, field, or tab *through the product
    >   editor's extension points*, or that linked to a block-editor product route. Those hooks no
    >   longer exist — port the UI to the classic screen (`add_meta_box()`, the standard product data
    >   tabs via `woocommerce_product_data_tabs` / `woocommerce_product_data_panels`).
    > - Don't feature-detect the flag any more; it is not coming back.
    >
    > **2. Action Scheduler 4.0.0 is bundled**, and its **uniqueness rule changed**:
    > - **`$unique` now includes the action's ARGUMENTS in the dedup check.** Previously only the
    >   hook + group were compared. Two `as_schedule_single_action()` calls with the same hook and
    >   group but *different args* used to block each other and now **both schedule**. If you relied
    >   on the old behaviour as a "only one of these ever queued" guard, that guard is gone — expect
    >   more actions, and dedupe on your own key if you need the old semantics.
    > - **Cleanup of old actions moved** from inline work on every queue batch to a **dedicated daily
    >   job at 3 AM site time.** Tests or monitoring that assumed old rows disappear during normal
    >   processing need adjusting (a custom queue cleaner can restore inline behaviour).
    > - Requires WP 6.8+. See `wordpress-apis.md` §5 for when to reach for Action Scheduler at all.
    >
    > **Also in 11.0 (experimental — don't ship against these yet):** abandoned-cart emails,
    > block-based email editing, and a new Settings UI.
    > Sources: developer.woocommerce.com/2026/08/04/woocommerce-11-0/ ·
    > developer.woocommerce.com/2026/06/02/product-editor-beta-retiring/ ·
    > developer.woocommerce.com/2026/06/17/changes-to-action-scheduler/
    
    ---
    
    ## HPOS Order API Rules
    
    **Always use these — never `get_post()` / `update_post_meta()` for orders:**
    
    ```php
    // ✅ Retrieve an order
    $order = wc_get_order( $order_id );   // returns WC_Order, works with both legacy and HPOS
    
    // ✅ Read order meta
    $value = $order->get_meta( '_my_meta_key' );
    
    // ✅ Write order meta
    $order->update_meta_data( '_my_meta_key', $value );
    $order->save();
    
    // ❌ NEVER use these for orders when HPOS is active:
    // $order = get_post( $order_id );           // returns WP_Post — wrong type, may be empty
    // update_post_meta( $order_id, ... );       // writes to postmeta, bypasses HPOS tables
    // get_post_meta( $order_id, '_key', true ); // reads from postmeta, stale if HPOS is authoritative
    ```
    
    ---
    
    ## Preferred: Elementor Loop Grid (No Template Files Touched)
    
    ```php
    // Use elementor-patterns.md Loop Grid pattern — set post_type to 'product'
    // Apply tax_query for product_cat / product_tag as needed
    // This is always safer than overriding template files
    add_action( 'elementor/query/myplugin_products_query', function( \WP_Query $query ) {
        $query->set( 'post_type',      'product' );
        $query->set( 'posts_per_page', 6 );
        $query->set( 'no_found_rows',  true );
    } );
    ```
    
    ---
    
    ## Hook-Based Additions (Safer Than Template Override)
    
    ```php
    add_action( 'woocommerce_after_add_to_cart_button', 'myplugin_after_cart_button' );
    function myplugin_after_cart_button(): void {
        // ✅ Use wc_get_product() — NOT global $product.
        // global $product works on standard single-product pages but returns null or the
        // wrong product inside Elementor popups, Quick View overlays, and AJAX-rendered
        // product templates. wc_get_product( get_the_ID() ) is reliable in all contexts.
        $product = wc_get_product( get_the_ID() );
        if ( ! $product instanceof WC_Product ) return;
    
        echo '<p class="myplugin-product__note">'
            . esc_html__( 'Free shipping on this item.', 'myplugin' )
            . '</p>';
    }
    ```
    
    ---
    
    ## Template Override (Last Resort — Always Document Why)
    
    ```
    Copy from: /wp-content/plugins/woocommerce/templates/
    Place at:  /wp-content/themes/mytheme-child/woocommerce/
    Maintain:  After every WooCommerce update, check WooCommerce → Status → System Status
               for "Outdated template files" warnings. WooCommerce compares the version
               declared in each template's @version comment against the bundled version.
               Outdated template overrides cause silently broken checkout flows.
    ```
    
  • wordpress-apis.md 16.4 KB
    # Common WordPress APIs — Options, Settings, Metadata, Capabilities, Cron, i18n
    
    > **When to read this file:** Building an **admin settings page**, persisting plugin data,
    > exposing meta to REST/Elementor, gating actions by capability, scheduling background work, or
    > making a plugin translatable. These are the WordPress "Common APIs" the other files don't cover
    > (REST → `rest-api.md`; transients/HTTP/sanitization → `php-standards.md`; CPT/AJAX →
    > `scaffolding.md`). For exact, current function signatures, the canonical lookup is the **Code
    > Reference**: developer.wordpress.org/reference/ — verify there before relying on memory.
    
    ---
    
    ## 1. Options API — storing plugin settings
    
    ```php
    $opts = get_option( 'myplugin_options', [] );   // ALWAYS pass a default
    
    // ✅ WP 6.6+: pass an explicit BOOLEAN $autoload — not the legacy 'yes'/'no' strings.
    update_option( 'myplugin_options', $opts, true );       // true  → autoloaded on every page
    update_option( 'myplugin_big_cache', $blob, false );    // false → NOT autoloaded (large/rare)
    add_option(    'myplugin_flag', '1', '', false );        // 3rd arg ($deprecated) stays ''
    delete_option( 'myplugin_options' );
    ```
    
    - **Store one option as an array**, not a dozen scalar options — fewer `wp_options` rows and a
      single autoload entry. Sanitize the whole array on write (see the Settings API below).
    - **`autoload` is a real performance lever.** Autoloaded options load on **every** request via the
      `alloptions` cache. Set `false` for anything large or rarely read (caches, logs, per-item blobs);
      bloated autoload is a common cause of slow sites. (WP 6.6 changed the default to "auto" so core
      can decide; be explicit anyway.) See `performance.md`.
    - Site-wide on multisite: `get_site_option()` / `update_site_option()`.
    
    ---
    
    ## 2. Settings API — the review-safe admin settings page
    
    The canonical pattern. `settings_fields()` emits the nonce, the **`sanitize_callback` is the single
    trusted place to clean input**, and field renderers **escape on output** — together that satisfies
    Plugin Check and wp.org review.
    
    ```php
    // (a) Register the setting + sections + fields on admin_init.
    add_action( 'admin_init', function () {
    
        register_setting( 'myplugin_group', 'myplugin_options', [
            'type'              => 'array',
            'sanitize_callback' => 'myplugin_sanitize_options',  // ← the only trusted cleaning point
            'default'           => [ 'api_key' => '', 'enabled' => false ],
            'show_in_rest'      => false,                          // true only with a defined schema
        ] );
    
        add_settings_section(
            'myplugin_main',
            esc_html__( 'Main Settings', 'myplugin' ),
            '__return_false',          // optional intro callback
            'myplugin'                 // page slug (matches do_settings_sections below)
        );
    
        add_settings_field(
            'myplugin_api_key',
            esc_html__( 'API Key', 'myplugin' ),
            'myplugin_field_api_key',
            'myplugin',
            'myplugin_main'
        );
    } );
    
    // (b) Sanitize callback — clean EVERY field; never trust $input.
    function myplugin_sanitize_options( $input ): array {
        return [
            'api_key' => sanitize_text_field( $input['api_key'] ?? '' ),
            'enabled' => ! empty( $input['enabled'] ),
        ];
    }
    
    // (c) Field renderer — escape on output.
    function myplugin_field_api_key(): void {
        $opts = get_option( 'myplugin_options', [] );
        printf(
            '<input type="text" name="myplugin_options[api_key]" value="%s" class="regular-text" autocomplete="off">',
            esc_attr( $opts['api_key'] ?? '' )
        );
    }
    
    // (d) The admin page — capability-gated, with the settings_fields() nonce.
    add_action( 'admin_menu', function () {
        add_options_page(
            esc_html__( 'My Plugin', 'myplugin' ),   // <title>
            esc_html__( 'My Plugin', 'myplugin' ),   // menu label
            'manage_options',                         // capability
            'myplugin',                               // menu slug
            'myplugin_render_settings_page'
        );
    } );
    
    function myplugin_render_settings_page(): void {
        if ( ! current_user_can( 'manage_options' ) ) {
            return;
        }
        ?>
        <div class="wrap">
            <h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
            <form action="options.php" method="post">
                <?php
                settings_fields( 'myplugin_group' );   // nonce + option_page + _wp_http_referer
                do_settings_sections( 'myplugin' );    // renders the sections + fields
                submit_button();
                ?>
            </form>
        </div>
        <?php
    }
    ```
    
    > ⚠️ **The `sanitize_callback` runs on EVERY `update_option()` for that option — including your
    > own programmatic writes.** `register_setting()` installs a `sanitize_option_{$option}` filter
    > that fires inside `update_option()` regardless of who calls it (whenever the registering code
    > has loaded — any `is_admin()` request, including `admin-post.php`). Three rules from a real
    > "activation silently reverted" bug:
    > - The callback must be a **pure function of `$input`** — never "preserve the current stored
    >   value" for a key, and beware side effects. A "preserving" sanitizer silently reverted the
    >   plugin's own `update_option( …, true )` on every activation click.
    > - **Runtime state does not belong in a registered settings array.** Flags your code toggles
    >   (an "enabled" state, counters, timestamps) live in their **own unregistered option**; inject
    >   them at read time if you want a single accessor.
    > - **WP-CLI smoke tests miss this bug class**: under CLI, `is_admin()` is false → the settings
    >   page never loads → `register_setting()` never ran → the filter is absent. To reproduce admin
    >   conditions, `add_filter( 'sanitize_option_myplugin_options', 'myplugin_sanitize_options' )`
    >   before testing option writes.
    
    > **Secrets in settings:** never re-render an API secret into `value="…"` — show an empty field
    > with a "leave blank to keep" sanitizer and store it encrypted (see `field-notes.md` §6).
    
    > **When options/meta are the wrong tool:** high-volume rows (logs, events, analytics, queues)
    > belong in a **custom table**, not in `wp_options` or post meta. `dbDelta()`, charset/collation,
    > indexing and the `varchar(191)` rule are in **`mariadb.md`**.
    
    ---
    
    ## 3. Metadata API — `register_meta` (REST- and Elementor-aware)
    
    ```php
    register_post_meta( 'myplugin_item', '_myplugin_subtitle', [
        'type'              => 'string',
        'single'            => true,
        'sanitize_callback' => 'sanitize_text_field',
        'auth_callback'     => function () { return current_user_can( 'edit_posts' ); },
        'show_in_rest'      => true,   // REST exposure + needed for Gutenberg / Elementor Dynamic Tags
    ] );
    
    $subtitle = get_post_meta( $post_id, '_myplugin_subtitle', true );
    update_post_meta( $post_id, '_myplugin_subtitle', sanitize_text_field( $subtitle ) );
    ```
    
    - **Prefix meta keys.** A leading underscore (`_myplugin_*`) marks the meta "protected" — hidden
      from the default Custom Fields UI and not editable by users directly.
    - **`show_in_rest` is required** for the value to drive an Elementor **Dynamic Tag** / Loop Grid or
      appear in the block editor. For arrays/objects pass a `show_in_rest` schema.
    - `register_term_meta()` / `register_user_meta()` follow the same shape.
    
    ---
    
    ## 4. Roles & Capabilities — gate by capability, never by role
    
    ```php
    // Authorisation check before any privileged action (nonces confirm ORIGIN, caps confirm PERMISSION):
    if ( ! current_user_can( 'edit_post', $post_id ) ) {   // meta-cap with the object id where relevant
        wp_die( esc_html__( 'You are not allowed to do this.', 'myplugin' ) );
    }
    
    // ❌ Never test a ROLE: current_user_can( 'administrator' ) is wrong — roles aren't capabilities.
    // ✅ Test a capability: 'manage_options', 'edit_posts', or a custom one.
    
    // Custom capability — add on activation, remove on uninstall (NOT on every page load).
    register_activation_hook( __FILE__, function () {
        foreach ( [ 'administrator', 'editor' ] as $role_name ) {
            get_role( $role_name )?->add_cap( 'myplugin_manage_items' );
        }
    } );
    // Then gate with: current_user_can( 'myplugin_manage_items' )
    ```
    
    ---
    
    ## 5. WP-Cron — scheduled tasks
    
    ```php
    // Schedule on activation, guarding against a duplicate event.
    register_activation_hook( __FILE__, function () {
        if ( ! wp_next_scheduled( 'myplugin_daily_task' ) ) {
            wp_schedule_event( time(), 'daily', 'myplugin_daily_task' );
        }
    } );
    add_action( 'myplugin_daily_task', 'myplugin_run_daily' );
    
    // ✅ ALWAYS clear on deactivation — an orphaned event keeps firing forever.
    register_deactivation_hook( __FILE__, function () {
        wp_clear_scheduled_hook( 'myplugin_daily_task' );
    } );
    
    // Custom interval (built-ins: hourly, twicedaily, daily, weekly).
    add_filter( 'cron_schedules', function ( array $s ): array {
        $s['myplugin_5min'] = [ 'interval' => 5 * MINUTE_IN_SECONDS, 'display' => esc_html__( 'Every 5 Minutes', 'myplugin' ) ];
        return $s;
    } );
    
    // One-off job with an argument:
    wp_schedule_single_event( time() + 10 * MINUTE_IN_SECONDS, 'myplugin_one_off', [ $item_id ] );
    ```
    
    > ⚠️ **WP-Cron is NOT a real system cron — it is triggered by site traffic.** On a low-traffic
    > site a "daily" task fires whenever the next visitor arrives, not at a fixed time. A *missed*
    > event is queued and runs on the next load (not abandoned). For **reliable timing**, disable the
    > traffic trigger and run a real server cron:
    > ```php
    > // wp-config.php
    > define( 'DISABLE_WP_CRON', true );
    > ```
    > ```cron
    > # crontab — every 5 minutes, headless:
    > */5 * * * * cd /path/to/wp && wp cron event run --due-now > /dev/null 2>&1
    > ```
    > For **heavy, high-volume, or must-complete** background work (imports, bulk emails, queues),
    > prefer **Action Scheduler** (the battle-tested queue bundled with WooCommerce) over raw WP-Cron —
    > it persists jobs to the DB, retries on failure, and processes in batches.
    >
    > ⚠️ **Action Scheduler 4.0.0 (bundled with WooCommerce 11.0, Aug 2026) changed `$unique`.**
    > Deduplication now compares the action's **arguments** as well as its hook and group. Two
    > `as_schedule_single_action()` calls that differ only in args used to block each other and now
    > **both schedule** — if you used `$unique` as a "only ever one of these queued" guard, that guard
    > is gone; dedupe on your own key instead. Cleanup of old actions also moved from inline
    > processing to a **daily 3 AM job**. Requires WP 6.8+.
    > Source: developer.woocommerce.com/2026/06/17/changes-to-action-scheduler/
    
    ---
    
    ## 6. Internationalization (i18n) — required for the directory
    
    ```php
    // Header: "Text Domain: myplugin" — the text domain MUST EQUAL the plugin slug (Golden Rule #7).
    
    // wp.org-HOSTED plugin: add NOTHING. Do not call load_plugin_textdomain().
    // Translations arrive as language packs and load themselves. See the trap below.
    
    // PRIVATE / self-hosted plugin only — this is the ONLY case where you need it:
    add_action( 'init', function () {
        load_plugin_textdomain( 'myplugin', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
    } );
    ```
    
    > 🚨 **The bundled-`.mo` trap — you cannot ship translations inside a wp.org-hosted plugin.**
    > This looks like it should work and silently doesn't, so read the mechanism once:
    >
    > 1. WordPress resolves a plugin's translations through `WP_Textdomain_Registry`
    >    (`wp-includes/class-wp-textdomain-registry.php`). Its `get_path_from_lang_dir()` scans
    >    **`WP_LANG_DIR/plugins/`** — i.e. `wp-content/languages/plugins/` — and **nothing else.**
    >    It never looks inside your plugin's own `/languages/` folder.
    > 2. So a `.mo` you ship in `myplugin/languages/` is **never loaded**. Your `.pot` is fine there
    >    (it is a template, not a catalogue), but the compiled files are dead weight.
    > 3. The only thing that changes this is `load_plugin_textdomain()`, which registers a *custom
    >    fallback path* on the registry — and **Plugin Check flags that call as discouraged**:
    >    *"load_plugin_textdomain() has been discouraged since WordPress version 4.6. When your plugin
    >    is hosted on WordPress.org, you no longer need to manually include this function call."*
    > 4. Net effect for a hosted plugin: bundling is either **dead weight** (no call) or a **review
    >    warning** (with the call). Neither is what you want.
    >
    > **The supported route:** ship only the `.pot`; translations live on
    > [translate.wordpress.org](https://translate.wordpress.org/) and are delivered as **language
    > packs**, which install to `WP_LANG_DIR/plugins/` — exactly where the loader already looks.
    > Note the precedence is the right way round: a language pack always wins, and a custom path is
    > only a fallback, so an author-supplied catalogue can never shadow the community translation.
    
    ### Getting a locale actually translated (the parts that bite)
    
    - **90% or nothing.** wordpress.org generates the first language pack only once **≥90%** of that
      locale's strings are approved as **Current**. A "just the user-facing strings" catalogue
      typically lands near 35–40% and will **never** produce a pack. Translate the admin/settings/
      control-label strings too, or the work ships to nobody.
    - **Anyone can import; only editors can approve.** Any wp.org user can use *Import Translations*
      on a plugin/theme project (the link appears at the bottom of a translation-set page **when
      logged in**). Imported strings land as **Waiting**. To set them Current you need **PTE** for
      that locale — request it on the front page of `make.wordpress.org/polyglots/` using the
      `#locale_code` tag (e.g. `#de_DE`) so the locale's GTEs are notified.
    - **Build the catalogue from the GlotPress export, not your local `.pot`.** Download the locale's
      PO from the project and fill *that* in. Two reasons: the `msgid`s then match the project
      exactly, and the export carries the locale's real **`Plural-Forms`**. These disagree — a
      locally generated POT gave `nplurals=2` for Georgian while GlotPress uses `nplurals=1`.
    - **Validate placeholders before importing.** A translation that loses or renames a `%s` / `%d` /
      `%1$s`, or drops an HTML tag, breaks output at runtime and passes every PHP lint. Diff the
      placeholder and tag sets between `msgid` and `msgstr` programmatically.
    - **`.l10n.php` is the fast path.** WP 6.5+ prefers `myplugin-{locale}.l10n.php` over `.mo`;
      the registry checks for both. Generate with `wp i18n make-php`.
    - **Interim use while you wait for a pack:** drop the compiled files into the site's own
      `wp-content/languages/plugins/`. That is the language-pack location, so the loader finds them
      with no plugin code and no Plugin Check warning, and a real pack later overwrites them.
    - **Machine translation is not acceptable unreviewed.** Polyglots states MT *"without human
      review…will NOT be considered acceptable"*. Have a native speaker read it before approving.
    
    > ⚠️ **WP 6.7+ — don't translate before `init`.** Calling `__()` / `_e()` / `esc_html__()` for your
    > text domain **earlier than the `init` action** now triggers a `_doing_it_wrong` notice:
    > *"Translation loading for the `myplugin` domain was triggered too early."* Don't translate at
    > file-load, `plugins_loaded`, or in a class constructor that runs early. `register_post_type`
    > labels, widget control labels, settings fields, etc. all run on/after `init` — those are fine.
    > Debug the offender: hook `doing_it_wrong_run` and `debug_print_backtrace()` when the function is
    > `_load_textdomain_just_in_time`.
    > Source: make.wordpress.org/core/2024/10/21/i18n-improvements-6-7/
    
    ```php
    // Functions — always use the ESCAPING variants at output:
    esc_html__( 'Save', 'myplugin' );          esc_html_e( 'Save', 'myplugin' );
    esc_attr__( 'Close', 'myplugin' );          esc_attr_e( 'Close', 'myplugin' );
    
    // Plurals + placeholders (add a translator comment for context):
    printf(
        /* translators: %s: number of items */
        esc_html( _n( '%s item', '%s items', $count, 'myplugin' ) ),
        esc_html( number_format_i18n( $count ) )
    );
    _x( 'Post', 'noun', 'myplugin' );   // disambiguate same word, different meaning
    
    // JavaScript strings (wp.i18n / block editor):
    wp_set_script_translations( 'myplugin-js', 'myplugin', plugin_dir_path( __FILE__ ) . 'languages' );
    ```
    
    - **Never** interpolate a variable into a translation string (`__( "Hi $name" )`) — use a
      placeholder (`sprintf( __( 'Hi %s', 'myplugin' ), $name )`) so the string is extractable.
    - Generate the template: **`wp i18n make-pot . languages/myplugin.pot`** (WP-CLI).
    - Plugin Check's **Internationalization** category flags a wrong/missing text domain and
      variable-in-string usage.
    
  • wp-org-guidelines.md 12 KB
    # WordPress.org Plugin Directory — Guidelines, Plugin Check & Review Reality
    
    > **When to read this file:** Any plugin destined for the **wordpress.org directory**. These are
    > the *policy* rules that get a plugin accepted or rejected — distinct from `field-notes.md`
    > (the practical "how it breaks" gotchas) and `php-standards.md` (the security *implementation*).
    > Read all three for a submission. Canonical, always-current source — verify against it before
    > relying on any summary:
    > developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/
    
    ---
    
    ## The 18 Detailed Plugin Guidelines (paraphrased — actionable form)
    
    Reviewers cite these by number ("this fails Guideline 8"). Keep the numbering.
    
    1. **GPL-compatible license.** All code, bundled libraries, data, and images must be
       **"GPLv2 or later" or another GPL-compatible license.** No "all rights reserved" assets.
    2. **The developer is responsible for all files** in the plugin and for following the
       guidelines. Deliberately circumventing a rule is itself a violation.
    3. **A stable version must be available from the directory** (your SVN `Stable tag`). Don't ship
       the real code only from your own site while the directory hosts a stub.
    4. **Human-readable source — no obfuscation.** No packed/minified-only/encoded PHP whose source
       isn't included or clearly linked. JS/CSS may be minified **if** the unminified source is
       included or referenced. p,a,c,k,e,r and similar are rejected.
    5. **No "trialware."** Functionality may not be crippled, time-locked, or disabled pending
       payment, and the plugin may not be a sandbox-only/demo. (Up-sells to a Pro version are fine;
       nagging or disabling core features is not.)
    6. **SaaS is allowed** when the third-party service provides *substantial* functionality. Pure
       **license-key validation servers** and **storefront-only** plugins are not — the service must
       do real work, not just gate the plugin.
    7. **No phoning home / tracking without explicit, opt-in consent.** Don't contact external
       servers or load remote assets on activation by default. Telemetry must be **opt-in**, clearly
       disclosed, with a privacy policy. (A genuine SaaS the user signed up for is the exception.)
    8. **No executable code pulled from remote servers.** No self-updating from outside wordpress.org,
       no fetch-and-`eval()`, no loading PHP from a CDN. The directory is the only update source.
    9. **No illegal or dishonest actions** — black-hat SEO, fake/incentivized reviews, sockpuppets,
       stolen code, botnets, crypto-mining, spam.
    10. **No required credits / "Powered by" links.** Any front-end credit/attribution link must be
        **opt-in and default to OFF.**
    11. **Don't hijack the admin.** Notices must be relevant, contextual, and **dismissible**; no
        persistent dashboard takeovers or unrelated upsell banners. Admin notices should auto-clear
        once the condition is resolved.
    12. **No readme spam.** ≤ **5 tags**, no competitor/brand keyword stuffing, no affiliate-link
        farms, no black-hat SEO in the readme or other public-facing text.
    13. **Use WordPress's bundled libraries** — `jquery`, `SimplePie`, `PHPMailer`, etc. — rather than
        bundling your own copy. (Ties to the skill's "no jQuery unless it's a WP-core dependency" rule
        and "declare `'swiper'` as a dependency, don't ship your own.")
    14. **Don't abuse the SVN repo with frequent junk commits.** SVN is a *release* repo; commit
        deploy-ready code, not dozens of "wip/typo" revisions.
    15. **Increment the version on every release** so users get the update notification. (See the
        lockstep version-bump checklist in `field-notes.md` §10.)
    16. **The plugin must be complete and functional at submission.** Names/slugs can't be reserved
        for future work; an empty or stub plugin is rejected.
    17. **Respect trademarks.** A slug may not **start** with a trademarked term you don't own. Use
        `"… for Brand"` / `"Tool for Brand"`, never `"Brand Tool"`. (Affects `get_name()` /
        text-domain / slug choices.)
    18. **The directory reserves the right** to update guidelines, disable a plugin for user safety,
        grant case-by-case exceptions, and push emergency security fixes without author consent.
    
    ---
    
    ## Plugin Check (PCP) 2.1.0 — the reviewer's own tool
    
    The official plugin (by the WordPress.org team) that runs **most of the checks used for new
    submissions**. Requires WP 6.3+ / PHP 7.4+. **Run it until your own code is 0 findings before
    every submission and resubmission** (standing project rule). Passing is necessary but **not
    sufficient — human review is still mandatory.**
    
    > 🆕 **New in 2.1.0 (Aug 16, 2026)** — three of these will flag plugins that passed under 2.0.0:
    > - **SVN Checker** — scans the plugin's **wp.org SVN repository** for unexpected files. It sees
    >   what you committed to `trunk/`, not just what is in your zip, so a stray dev file that
    >   survived in SVN now surfaces even when your build is clean. (See the `svn/` sub-bundle.)
    > - **PHP Error Reporting check** — flags production-time modification of error settings, i.e.
    >   calls like `ini_set( 'display_errors', … )` / `error_reporting( … )` in shipped code.
    > - **Per-dependency `Requires Plugins` validation** — each slug in the header is now checked
    >   **individually** against the wp.org directory. A typo'd or non-directory slug (a Pro-only or
    >   self-hosted dependency) is an error; only directory-hosted slugs belong in that header.
    > - Plus: distinct visual styles per issue type, Unicode preserved in exported results, and
    >   improved WordPress-function compatibility data (feeding the `Requires at least` gate below).
    
    **Check categories:**
    - **Plugin Repository Requirements** — directory-guideline compliance (headers, readme, naming,
      no disallowed functions, trademark/branding).
    - **Security** — sanitization, escaping, nonces, SQL prep, file operations.
    - **Performance** — enqueue patterns, asset optimization.
    - **Accessibility** — accessible-markup checks.
    - **Code Standards** — PHPCS (WordPress Coding Standards) — emits the `WordPress.Security.*`,
      `WordPress.NamingConventions.*`, etc. codes.
    - **Internationalization** — correct use of i18n functions and a single, matching text domain.
    
    **How to run:**
    ```bash
    # WP-CLI (preferred for CI / scripted checks):
    wp plugin check <your-plugin-slug>
    wp plugin check <slug> --format=json --exclude-directories=includes/lib
    ```
    - **Static checks run by default.** **Runtime checks** require loading the plugin's `cli.php`
      first: add `--require .../cli.php` before WordPress boots.
    - Admin UI: **Tools → Plugin Check**.
    - Since **October 2025, Plugin Check also runs automatically on every plugin update**, not just
      new submissions — a security regression in an update can get the plugin flagged/closed.
    
    See `field-notes.md` §3 for how PCP flags `echo $var` / `echo $this->method()`, the
    `phcs:ignore` philosophy, and the small set of *justified* false positives (core hook names,
    cache-opt-out constants, excluded vendored libs).
    
    ---
    
    ## The review process — what actually happens (2025–2026)
    
    - **Reviews are AI-assisted but human-decided.** Automated first-pass tooling handles the bulk
      (the team added 80+ internal checks and Plugin Check auto-scans), but a person makes the call —
      "these tools support reviewers; they don't replace them."
    - **Queue is typically days to ~a week** (submission volume surged to hundreds/week through 2026;
      the team scaled reviewers to keep turnaround short). Don't assume instant approval; build the
      submission cleanly the first time.
    - **Author responsiveness strongly correlates with approval.** Approved plugins average **several
      review cycles**, and a large share of rejected ones simply **never replied to the reviewer's
      email**. When a reviewer writes, **reply promptly and address every point** — silence is the
      most common path to rejection.
    - **Fix the source, don't argue or suppress.** Reviewers grep for hand-wavy `phcs:ignore`
      justifications and known evasion patterns (see `field-notes.md` §2–3).
    - **The review is multi-round and ESCALATES.** Round 1 is commonly an automated
      **AUTOPREREVIEW** (bot + AI-generated notes); later rounds are a **human reviewer** who catches
      what the bot missed and looks harder each pass. Fix the whole *class* of a flagged issue and
      self-audit for other occurrences — reviewers state "we may not share all cases."
    - **Reply in the SAME email thread** — concise, no change-logs, no AI filler. Reviewers re-review
      the **whole plugin**, they don't diff your changes; one complete, well-tested update per round
      beats many partial ones (each round can take days to weeks).
    
    ---
    
    ## Hard lines from real 2026 reviews — not spelled out in the guidelines
    
    - **No user-authored SQL over any endpoint — guards cannot fix it.** A tool running free-form
      caller-supplied `SELECT` was rejected even with SELECT-only checks, credential-column blocks,
      secret redaction and admin auth: free-form SQL **cannot be `prepare()`d** (the entire string is
      the input — no placeholders to bind), and exposing arbitrary DB reads over a remote,
      token-authenticated REST endpoint is an aggravating factor. The redesign that passed: a
      **structured query the plugin assembles itself** — **identifiers** (table, columns) validated
      against the **live schema** (`SHOW TABLES` / `DESCRIBE`) plus `^[a-zA-Z0-9_]+$`, then
      interpolated in backticks; **values** bound via `$wpdb->prepare()`. Rule of thumb for ANY
      user-input-to-DB feature: **whitelist identifiers, parameterize values.** If a feature only
      works by running user-authored SQL, it does not belong in a wp.org plugin (ship it GitHub-only,
      filter-gated, instead).
    - **Never use `AUTH_KEY` / auth salts outside core authentication** — not even to derive an
      opaque one-way HMAC identifier. Reviewers flag it; generate a dedicated plugin secret option
      instead.
    - **Document every external service in a `== External services ==` readme section** — what it is,
      what data is sent, when, plus ToS/privacy links. Required **even when the service connects
      INBOUND** and the plugin never phones out.
    - **Automated nonce flags on protocol endpoints are answered, not "fixed".** OAuth
      `/authorize` + `/token` (secured by PKCE + single-use codes) legitimately have no WP nonce —
      explain that in the reply; do **not** bolt on a meaningless nonce. (Admin-facing forms still
      verify `check_admin_referer()` + capability, always.)
    - **A standalone HTML page (echoed + `exit`, no `wp_head()`) still may not emit a raw `<style>`
      or `<link>`.** The pattern that satisfies both Plugin Check and the reviewer:
      `wp_register_style()` + `wp_enqueue_style()`, then print by handle with
      `wp_print_styles( 'my-handle' )` inside your own `<head>` — and move inline `style=""`
      attributes into that stylesheet too.
    
    ---
    
    ## Required headers & readme (cross-reference)
    
    A compliant submission needs, in lockstep (full checklist in `field-notes.md` §10):
    - **Main file header:** `Plugin Name`, `Version`, `Requires at least`, `Requires PHP`,
      `License: GPL-2.0-or-later`, `Text Domain`, and `Requires Plugins:` if it depends on Elementor.
    - **`readme.txt`:** `Stable tag` (must match the released version), `Requires at least` (the
      REAL feature floor — see the compatibility-gate note in `debugging.md` §1), `Tested up to`
      (**must equal the current WP major** or the readme check errors), `Requires PHP`, `License`,
      ≤ 5 tags, a `== Changelog ==`, and an `== Upgrade Notice ==` (< 300 chars each).
    - **Listing assets** (`screenshot-N.png` + captions, `banner-*`, `icon-*`) live in SVN
      `/assets/`, **not** in the plugin zip.
    
    > 🚀 **Actually deploying to the directory** (the `svn co` → copy to `trunk/` → `svn cp trunk
    > tags/X.Y.Z` → `svn ci` release dance, plus updating `/assets/`) is a Subversion task — see the
    > **`svn/`** sub-bundle (`svn/svn.md`), which has a WordPress.org-specific worked example.
    
    > Source of record (re-check before each submission, guidelines do change):
    > developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/ ·
    > wordpress.org/plugins/plugin-check/ · make.wordpress.org/plugins/ (review-team announcements)
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related