Basic · 2 of 5

Hello World

Click Save and watch JSON round-trip to disk. Same builder as Quick Start, plus a 30-line PSR-12 backend that you can swap for any database.

Walkthrough

  1. Wire your "Save" button to the engine

    There is no save:click event — your save button is a plain <button> you wire up. Two correct patterns:

    (a) Engine-driven. Pass saveUrl to the constructor and call builder.save(). The engine reads getHtml() + getData(), POSTs them URL-encoded as html + data, and resolves with the JSON response. Caveat: builder.save() only sends those two keys — if your backend needs a slug or a directory, use pattern (b).

    (b) Hand-rolled. Read state yourself, POST any shape your backend wants. The Save button in the live demo above runs exactly the snippet below — same #b2SaveBtn, same fetch, same status pill.

    JavaScript
    // (a) Engine-driven — saveUrl in constructor.
    const builder = new Builder({
      mainContainer:     '#MyCanvas',
      widgetsContainer:  '#MyWidgets',
      settingsContainer: '#MySettings',
      saveUrl:           '/backend/save.php',
    });
    // builder.save() POSTs html + data only — does NOT include dir/sample.
    // Use this if your backend accepts (html, data); use (b) for richer shapes.
    
    // (b) Hand-rolled — what the live demo above runs.
    document.getElementById('b2SaveBtn').addEventListener('click', async () => {
      const status = document.getElementById('b2SaveStatus');
      status.textContent = 'Saving...';
      const fd = new FormData();
      fd.append('dir',    '_b2_demo');                 // theme dir (regex [A-Za-z0-9_-]+)
      fd.append('sample', 'sample/HelloWorld');        // sample path under the theme dir
      fd.append('html',   builder.getHtml());          // string — rendered page
      fd.append('data',   JSON.stringify(builder.getData())); // page-tree JSON
      const res = await fetch('/backend/save.php', { method: 'POST', body: fd });
      const j   = await res.json();
      status.textContent = j.status === 'success' ? 'Saved ✓' : ('Error: ' + (j.message || 'unknown'));
    });
  2. Validate + write on the server

    Every $_POST key flows through backend/_lib/Validator.php. dir is regex-bounded so the storage key can never carry traversal characters; sample is path-bounded (no .. segments); html + data have explicit byte caps that fire before a multi-megabyte body hits disk. The shipped demo/backend/save.php writes themes/<dir>/<sample>.{html,json} via atomic rename — see the full handler in the code block below.

    PHP
    use DemoBuilder\Validator;
    use DemoBuilder\JsonResponse;
    
    $validated = Validator::make($_POST)
        ->required('dir')   ->regex('/^[A-Za-z0-9_\-]+$/')->maxLength(64)
        ->required('sample')->path()                       ->maxLength(200)
        ->required('html')                                 ->maxLength(5_000_000)
        ->required('data')                                 ->maxLength(5_000_000)
        ->validateOrFail();
    
    $dir  = __DIR__ . '/../themes/' . $validated['dir'];
    $path = $dir . '/' . $validated['sample'];
    
    // Atomic-write — write to a tempfile, rename into place. Crash-safe.
    file_put_contents($path . '.html', $validated['html']);
    file_put_contents($path . '.json', $validated['data']);
    
    JsonResponse::success(['saved' => true]);
  3. Round-trip on next load

    On the next page boot, hand builder.load() the saved JSON instead of the seed sample. The canvas paints exactly where the user left off — every block, every format, every uploaded asset. The 4-arg signature is builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL); only the first arg changes — the templates / config / media URL come from the same server-resolved bundle as in Quick Start.

    JavaScript
    // THEME_TEMPLATES / THEME_CONFIG_DATA / MEDIA_URL are the same
    // server-rendered globals Quick Start emits. Only `THEME_JSON` differs
    // — load the user's saved page instead of the seed sample.
    const savedJson = await fetch('/themes/_b2_demo/sample/HelloWorld.json').then(r => r.json());
    
    builder.load(
      savedJson,
      THEME_TEMPLATES,
      THEME_CONFIG_DATA,
      MEDIA_URL
    );

Live demo

demo-mini-builder--minimal
Hello world

The whole snippet

Click Copy on any file to grab it byte-identical, or Expand to read inline (capped at ~520 px so the page stays scannable). Multi-file snippets surface a side-by-side grid — copy only the files you need.

2 files · 187 lines · 134.8 KB

Server resolves the bundle once via ThemeRegistry::resolveBundle(); backend/save.php handles the Save POST. Recommended for production.

<?php
/**
 * snippet.php — minimal working BuilderJS host page WITH a save button.
 *
 * Drop this file alongside `dist/`, `themes/default/`, and the W2-shipped
 * `backend/` dir, run `php -S localhost:8000`, open
 * http://localhost:8000/snippet.php — you get a fully-rendered drag-drop
 * builder canvas with the "Minimal" sample loaded plus a header bar
 * carrying a Save button and a status pill.
 *
 * Click "Save" → fetch /backend/save.php → atomic-write
 *   themes/_b2_demo/sample/HelloWorld.{html,json}
 *
 * Triple-surface parity (D13.B.parity):
 *   This file's #b2SaveBtn + #b2SaveStatus IDs match the live demo on
 *   /examples/basic/2-hello-world/ AND match Step 1's walkthrough code.
 *   Copy any one surface and the references resolve everywhere.
 *
 * Why a save button (and not `builder.save()`):
 *   The engine's `builder.save()` POSTs only `html` + `data`. The shipped
 *   /backend/save.php also wants `dir` + `sample` so it knows WHERE on
 *   disk to write. Hand-rolling the fetch lets you add those (and any
 *   other keys — CSRF, user-id, etc.) without changing the engine.
 *
 * After this snippet works, the buyer-modifiable config surface is the
 * `$builderConfig` block at the top of `demo/builder.php`.
 */
declare(strict_types=1);

require_once __DIR__ . '/../../../backend/_lib/ThemeRegistry.php';

$bundle = (new \DemoBuilder\ThemeRegistry(__DIR__ . '/../../../themes'))
    ->resolveBundle('default', 'master/sample/email/Minimal');
?>
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>My Page Builder — Hello World</title>
    <link rel="stylesheet" href="/dist/builder.css">
    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=block">
    <style>
        body { margin: 0; font-family: system-ui, sans-serif; display: grid; grid-template-rows: 48px 1fr; height: 100vh; }
        .my-header        { display: flex; align-items: center; gap: 8px; padding: 0 16px; background: #f8f9fa; border-bottom: 1px solid #e5e7eb; }
        .my-header__title { margin-right: auto; font-weight: 600; font-size: 14px; }
        .my-header__btn   { padding: 6px 12px; background: #2563eb; color: #fff; border: 0; border-radius: 4px; font: inherit; cursor: pointer; }
        .my-header__btn:hover  { background: #1d4ed8; }
        .my-header__status     { margin-left: 8px; font-size: 11px; color: #6b7280; }
        #MyCanvas         { overflow: auto; }
    </style>
</head>
<body>

<header class="my-header">
    <span class="my-header__title">Hello world</span>
    <button type="button" id="b2SaveBtn" class="my-header__btn">Save</button>
    <span id="b2SaveStatus" class="my-header__status"></span>
</header>

<div id="MyCanvas"></div>

<script>
    // PHP → JS handoff: four named ingredients for builder.load(), in order.
    window.THEME_JSON        = <?= json_encode($bundle->themeJson,      JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
    window.THEME_TEMPLATES   = <?= json_encode($bundle->themeTemplates, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
    window.THEME_CONFIG_DATA = <?= json_encode($bundle->configData,     JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
    window.MEDIA_URL         = <?= json_encode($bundle->mediaUrl,       JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
</script>

<script src="/dist/builder.js"></script>

<script>
    // Four arguments, four named ingredients above — IDENTICAL to snippet.html.
    const builder = new Builder({
        mainContainer:    '#MyCanvas',
        widgetsContainer: null,    // hide widgets palette in this minimal demo
        settingsContainer: null,   // hide settings panel — focus on the save flow
    });
    builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL);
    window.builder = builder; // expose for browser-console + legacy globals

    // Hand-rolled save — POST dir+sample+html+data to /backend/save.php.
    document.getElementById('b2SaveBtn').addEventListener('click', async () => {
        const status = document.getElementById('b2SaveStatus');
        status.textContent = 'Saving...';
        const fd = new FormData();
        fd.append('dir',    '_b2_demo');
        fd.append('sample', 'sample/HelloWorld');
        fd.append('html',   builder.getHtml());
        fd.append('data',   JSON.stringify(builder.getData()));
        try {
            const res = await fetch('/backend/save.php', { method: 'POST', body: fd });
            const j   = await res.json();
            status.textContent = j.status === 'success'
                ? 'Saved ✓ → themes/_b2_demo/sample/HelloWorld.{html,json}'
                : ('Error: ' + (j.message || 'unknown'));
        } catch (err) {
            status.textContent = 'Error: ' + err.message;
        }
    });
</script>
</body>
</html>

Every variable hardcoded inline. Canvas mounts via file://; the Save button needs a PHP server (it POSTs to /backend/save.php). Regen the bundle with php scripts/demo/dump-bundle.php default master/sample/email/Minimal --media-url=../../../themes/default.

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>My Page Builder — Hello World — Pure HTML</title>
    <link rel="stylesheet" href="../../../dist/builder.css">
    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=block">
    <style>
        body { margin: 0; font-family: system-ui, sans-serif; display: grid; grid-template-rows: 48px 1fr; height: 100vh; }
        .my-header        { display: flex; align-items: center; gap: 8px; padding: 0 16px; background: #f8f9fa; border-bottom: 1px solid #e5e7eb; }
        .my-header__title { margin-right: auto; font-weight: 600; font-size: 14px; }
        .my-header__btn   { padding: 6px 12px; background: #2563eb; color: #fff; border: 0; border-radius: 4px; font: inherit; cursor: pointer; }
        .my-header__btn:hover  { background: #1d4ed8; }
        .my-header__status     { margin-left: 8px; font-size: 11px; color: #6b7280; }
        #MyCanvas         { overflow: auto; }
    </style>
</head>
<body>

<header class="my-header">
    <span class="my-header__title">Hello world · pure HTML</span>
    <button type="button" id="b2SaveBtn" class="my-header__btn">Save</button>
    <span id="b2SaveStatus" class="my-header__status"></span>
</header>

<div id="MyCanvas"></div>

<!-- ════════════════════════════════════════════════════════════════════
     FOUR NAMED INGREDIENTS for builder.load() — see 1-quickstart for
     the full comment block. Same shape, same regen command.

     SAVE BUTTON CAVEAT (file:// only):
       The save POST below targets /backend/save.php — that endpoint is
       PHP, so it works only when this file is served by a PHP-aware
       server (e.g. `php -S localhost:8000` from project root). When
       opened via file:// the save button will fail with a network
       error, but the canvas itself renders fine — useful for
       reviewing markup without a server.

     ════════════════════════════════════════════════════════════════════ -->
<script>
window.THEME_JSON        = {"theme":"default","name":"PageElement","template":"Page","formats":{"background_color":"#F4F4F4","padding_top":0,"padding_right":0,"padding_bottom":0,"padding_left":0},"page_title":null,"blocks":[{"name":"BlockElement","template":"Block","formats":{"padding_top":50,"padding_bottom":20},"elements":[{"name":"LinkElement","template":"Link","formats":{"text_color":"#000000","text_align":"center","text_decoration":"underline"},"text":"View this email in your browser","url":"{{WEB_VIEW_URL}}","target":"","rel":"","title":"","download":"","ariaLabel":""}]},{"name":"BlockElement","template":"Block","formats":{"padding_top":20,"padding_bottom":20},"elements":[{"name":"ImageElement","template":"Image","formats":{"width":130,"align":"center","border_radius":0},"src":"master/assets/image/email/logo.png","alt":"","url":"","target":"","rel":"","title":"","download":"","effect":{"grayscale":null,"sepia":null,"invert":null,"blur":null,"brightness":null,"contrast":null,"saturate":null,"hueRotate":null,"opacity":null},"crop":{"enabled":false,"ratioX":3,"ratioY":2,"zoom":1,"posX":50,"posY":50}}]},{"name":"BlockElement","template":"Block","formats":[],"elements":[{"name":"HeadingElement","template":"Heading","formats":{"text_align":"center","padding_bottom":20},"type":"h1","text":"It's time to design your email"},{"name":"PElement","template":"P","formats":{"text_align":"center"},"text":"You can define the layout of your email and give your content a place to live by adding, rearranging, and deleting content blocks."}]},{"name":"BlockElement","template":"Block","formats":[],"elements":[{"name":"ImageElement","template":"Image","formats":{"width":"100%","align":"center","border_radius":0},"src":"master/assets/image/email/banner_wide.png","alt":"","url":"","target":"","rel":"","title":"","download":"","effect":{"grayscale":null,"sepia":null,"invert":null,"blur":null,"brightness":null,"contrast":null,"saturate":null,"hueRotate":null,"opacity":null},"crop":{"enabled":false,"ratioX":3,"ratioY":2,"zoom":1,"posX":50,"posY":50}}]},{"name":"BlockElement","template":"Block","formats":[],"elements":[{"name":"ButtonElement","template":"Button","formats":{"align":"center","font_family":"inherit","font_weight":"400","font_size":18,"text_color":"#FFFFFF","link_color":"#ffffff","text_align":"center","line_height":"1.5","text_direction":"ltr","background_color":"#000000","background_position":"center","background_size":"100%","background_repeat":"no-repeat","padding_top":14,"padding_right":24,"padding_bottom":14,"padding_left":24,"border_top_style":"solid","border_top_width":0,"border_top_color":"#0d6efd","border_right_style":"solid","border_right_width":0,"border_right_color":"#0d6efd","border_bottom_style":"solid","border_bottom_width":0,"border_bottom_color":"#0d6efd","border_left_width":0,"border_left_style":"solid","border_left_color":"#0d6efd","border_radius":0},"text":"Register Now","url":"#","target":"","rel":"","title":"","download":"","ariaLabel":"","preset_id":"solid-ink","size_key":"lg","shadow_key":"none","hover_effect":"none","icon":null,"icon_position":"none"}]},{"name":"BlockElement","template":"Block","formats":[],"elements":[{"name":"DividerElement","template":"Divider","formats":{"background_color":"#333333","height":2,"divider_style":"solid","padding_top":10,"padding_bottom":10}}]},{"name":"BlockElement","template":"Block","formats":[],"elements":[{"name":"SocialIconsElement","template":"SocialIcons","formats":{"background_position":"center","background_size":"100%","background_repeat":"no-repeat"},"items":[{"link":"#","image_url":"master/assets/image/icons/facebook.png","label":"Facebook"},{"link":"#","image_url":"master/assets/image/icons/linkedin.png","label":"LinkedIn"},{"link":"#","image_url":"master/assets/image/icons/youtube.png","label":"YouTube"},{"link":"#","image_url":"master/assets/image/icons/twitter.png","label":"X"}],"size":35,"gap":30,"align":"center"}]},{"name":"BlockElement","template":"Block","formats":{"padding_top":20,"padding_bottom":20},"elements":[{"name":"ImageElement","template":"Image","formats":{"width":130,"align":"center","border_radius":0},"src":"master/assets/image/email/logo.png","alt":"","url":"","target":"","rel":"","title":"","download":"","effect":{"grayscale":null,"sepia":null,"invert":null,"blur":null,"brightness":null,"contrast":null,"saturate":null,"hueRotate":null,"opacity":null},"crop":{"enabled":false,"ratioX":3,"ratioY":2,"zoom":1,"posX":50,"posY":50}}]},{"name":"BlockElement","template":"Block","formats":{"padding_top":10,"padding_bottom":10},"elements":[{"name":"PElement","template":"P","formats":{"font_size":13,"text_align":"center"},"text":"Copyright (C) {{CURRENT_YEAR}}. All rights reserved."}]},{"name":"BlockElement","template":"Block","formats":[],"elements":[{"name":"PElement","template":"P","formats":{"font_size":13,"text_align":"center"},"text":"If you no longer want to receive these emails, you can <a href=\"{{UNSUBSCRIBE_URL}}\">unsubscribe</a>"}]}],"block_gap":0,"container_width":"medium","block_padding_bottom":20,"block_padding_top":20,"block_padding_right":20,"block_padding_left":20,"default_block_background_color":"#FFFFFF"};
window.THEME_TEMPLATES   = {"Page":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title><%= page_title %></title>\n\n    <style>\n        html {\n            margin: 0;\n        }\n        body {\n            padding: 0;\n            margin: 0;\n            font-family: <%= (typeof formats !== 'undefined' && formats && formats.font_family) ? formats.font_family : 'Arial, Helvetica, sans-serif' %>;\n            line-height: 1.4;\n        }\n        p, h1, h2, h3, h4, h5, h6, label, ul {\n            margin: 0;\n        }\n        a {\n            color: inherit;\n        }\n        [builder-element=\"PageElement\"] {\n            min-height: 100vh;\n            display: <%= (typeof formats !== 'undefined' && formats && formats.display) ? formats.display : ((typeof display !== 'undefined' && display) ? display : 'block') %>;\n            flex-direction: <%= (typeof formats !== 'undefined' && formats && formats.flex_direction) ? formats.flex_direction : ((typeof flex_direction !== 'undefined' && flex_direction) ? flex_direction : '') %>;\n            justify-content: <%= (typeof formats !== 'undefined' && formats && formats.justify_content) ? formats.justify_content : ((typeof justify_content !== 'undefined' && justify_content) ? justify_content : '') %>;\n            align-items: <%= (typeof formats !== 'undefined' && formats && (formats.align_items || formats.align)) ? (formats.align_items || formats.align) : ((typeof align_items !== 'undefined' && align_items) ? align_items : '') %>;\n        }\n\n        [builder-element=\"PageElement\"] > [builder-element=\"BlockElement\"] {\n            width: 100%;\n            margin-left: auto;\n            margin-right: auto;\n        }\n\n        [builder-element=\"PageElement\"] > [builder-element=\"BlockElement\"]:not(:last-child) {\n            margin-bottom: <%= (typeof block_gap !== 'undefined') ? block_gap : 0 %>px;\n        }\n\n        <%\n            var containerWidth = (typeof container_width !== 'undefined' && container_width) ? container_width : 'auto';\n        %>\n\n        <% if (containerWidth === 'narrow') { %>\n        @media (min-width: 800px) {\n            [builder-element=\"PageElement\"] > [builder-element=\"BlockElement\"] {\n                max-width: 600px;\n            }\n        }\n        <% } else if (containerWidth === 'medium') { %>\n        @media (min-width: 800px) {\n            [builder-element=\"PageElement\"] > [builder-element=\"BlockElement\"] {\n                max-width: 800px;\n            }\n        }\n        <% } else if (containerWidth === 'wide') { %>\n        @media (min-width: 900px) {\n            [builder-element=\"PageElement\"] > [builder-element=\"BlockElement\"] {\n                max-width: 1024px;\n            }\n        }\n        <% } else if (containerWidth === 'xl') { %>\n        @media (min-width: 1200px) {\n            [builder-element=\"PageElement\"] > [builder-element=\"BlockElement\"] {\n                max-width: 1200px;\n            }\n        }\n        <% } else if (containerWidth === 'full') { %>\n        [builder-element=\"PageElement\"] > [builder-element=\"BlockElement\"] {\n            max-width: none;\n            width: 100%;\n        }\n        <% } %>\n\n        <% if (typeof default_block_background_color !== 'undefined' && default_block_background_color) { %>\n        /* default_block_background_color targets the INNER styled div (`> :first-child`),\n           NOT the outer `<div builder-element=\"BlockElement\">` wrapper. The outer is a\n           hover/selection anchor with no painting role; the inner is where the formatter\n           writes per-block bg + border + radius + padding. Painting the default bg on\n           the outer (legacy behavior, retired 2026-05-05) caused outer's flat-edge\n           rectangle to visually mask any per-block `border_radius`. Targeting the inner\n           lets default bg compose with per-block radius — they paint the same div,\n           formatter's `!important` inline style still wins when set. */\n        [builder-element=\"PageElement\"] > [builder-element=\"BlockElement\"] > :first-child {\n            background-color: <%= default_block_background_color %>;\n        }\n        <% } %>\n\n        [builder-element=\"PageElement\"] > [builder-element=\"BlockElement\"] > :first-child {\n            padding-top: <%= (typeof block_padding_top !== 'undefined') ? block_padding_top : 0 %>px;\n            padding-right: <%= (typeof block_padding_right !== 'undefined') ? block_padding_right : 0 %>px;\n            padding-bottom: <%= (typeof block_padding_bottom !== 'undefined') ? block_padding_bottom : 0 %>px;\n            padding-left: <%= (typeof block_padding_left !== 'undefined') ? block_padding_left : 0 %>px;\n        }\n    </style>\n</head>\n<body>\n    <%- page %>\n</body>\n</html>","Form":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title><%= page_title %></title>\n\n    <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css\">\n\n    <style>\n        html {\n            margin: 0;\n        }\n        body {\n            padding: 0;\n            margin: 0;\n            font-family: <%= (typeof formats !== 'undefined' && formats && formats.font_family) ? formats.font_family : 'Arial, Helvetica, sans-serif' %>;\n            line-height: 1.4;\n        }\n        p, h1, h2, h3, h4, h5, h6, label, ul {\n            margin: 0;\n        }\n        [builder-element=\"PageElement\"] {\n            min-height: 100vh;\n            display: <%= (typeof formats !== 'undefined' && formats && formats.display) ? formats.display : ((typeof display !== 'undefined' && display) ? display : 'block') %>;\n            flex-direction: <%= (typeof formats !== 'undefined' && formats && formats.flex_direction) ? formats.flex_direction : ((typeof flex_direction !== 'undefined' && flex_direction) ? flex_direction : '') %>;\n            justify-content: <%= (typeof formats !== 'undefined' && formats && formats.justify_content) ? formats.justify_content : ((typeof justify_content !== 'undefined' && justify_content) ? justify_content : '') %>;\n            align-items: <%= (typeof formats !== 'undefined' && formats && (formats.align_items || formats.align)) ? (formats.align_items || formats.align) : ((typeof align_items !== 'undefined' && align_items) ? align_items : '') %>;\n        }\n\n        <% if (typeof default_block_background_color !== 'undefined' && default_block_background_color) { %>\n        [builder-element=\"PageElement\"] > [builder-element=\"BlockElement\"] {\n            background-color: <%= default_block_background_color %>;\n        }\n        <% } %>\n\n        [builder-element=\"PageElement\"] > [builder-element=\"BlockElement\"] > :first-child {\n            padding-top: <%= (typeof block_padding_top !== 'undefined') ? block_padding_top : 0 %>px;\n            padding-right: <%= (typeof block_padding_right !== 'undefined') ? block_padding_right : 0 %>px;\n            padding-bottom: <%= (typeof block_padding_bottom !== 'undefined') ? block_padding_bottom : 0 %>px;\n            padding-left: <%= (typeof block_padding_left !== 'undefined') ? block_padding_left : 0 %>px;\n        }\n    </style>\n</head>\n<body>\n    <%- page %>\n</body>\n</html>","Block":"<div style=\"<%- formatter.toStyleStringAll() %>\">\n    <%- elements %>\n</div>","Grid":"<div style=\"<%- formatter.toStyleStringAll() %> display: flex; width: 100%; gap: <%= cell_gap %>px; align-items: <%= align_items || 'stretch' %>;\">\n    <% cells.forEach(function(cell) { %>\n        <%- cell %>\n    <% }); %>\n</div>\n","Cell":"<div style=\"<%\n    var width = formatter.getFormat('width', 'auto');\n    var gap = (typeof cell_gap === 'number' && cell_gap > 0) ? cell_gap : 0;\n    var count = (typeof cell_count === 'number' && cell_count > 0) ? cell_count : 1;\n    var flexStyle = '';\n    if (width === 'auto' || width === null || width === '') {\n        // Natural content-based sizing — no flex grow/shrink.\n        flexStyle = '';\n    } else if (width === 'fill') {\n        // 2026-04-18 explicit \"fill remaining space\" primitive. The canonical\n        // way to say \"this cell grows to absorb whatever space the auto/fixed\n        // siblings don't use\". Renders as grow-share 1 with zero basis so the\n        // flex algorithm distributes remaining space evenly among all `fill`\n        // cells (typical use = exactly one `fill` per row). Replaces the\n        // implicit grow-share semantic of bare numbers that used to live in\n        // mixed `[\"auto\", 50]` grids — those are now `[\"auto\", \"fill\"]`.\n        flexStyle = 'flex: 1 1 0; min-width: 0;';\n    } else {\n        // `width` has two valid shapes — units decide intent:\n        //\n        //  1. CSS percentage (\"50%\", \"33.33%\"):\n        //     author wants THIS cell to occupy that share of the row's CSS\n        //     width. Emit as `flex-basis` clamped to 0/0 so the browser honors\n        //     the value exactly. When the grid also has a `cell_gap`, subtract\n        //     this cell's proportional share of gap from the basis — otherwise\n        //     two 50% cells + gap overflow the row (2×50% + gap > 100%). Gap\n        //     share = total_gap / count = (count - 1) × gap / count.\n        //     Cell sizes may still sum to < 100% — that's the author's call\n        //     (e.g. [15%, 70%] leaves a 15% slack on purpose).\n        //\n        //  2. Other CSS length with unit (\"200px\", \"15em\", \"30vw\"):\n        //     pass through as flex-basis unchanged — absolute units are\n        //     independent of container width so no gap compensation is possible\n        //     without overflowing.\n        //\n        //  3. Bare number (50, 100, 33.33): author wants a GROW-SHARE\n        //     ratio. Grid cells distribute horizontal space in proportion:\n        //     [50, 50] → 50/50 split, [30, 70] → 30/70, [1, 2] → 1:2.\n        //     Totals don't need to sum to anything specific and the flex\n        //     algorithm already accounts for gap automatically.\n        //\n        // Anything else (unparseable) falls back to equal share so we don't\n        // render a zero-width cell.\n        //\n        // `min-width: 0` lets content shrink below its intrinsic width so\n        // long words don't force overflow.\n        var str = String(width).trim();\n        if (/%$/.test(str)) {\n            var pct = parseFloat(str);\n            if (!isNaN(pct) && pct > 0) {\n                if (gap > 0 && count > 1) {\n                    var gapShare = (gap * (count - 1) / count);\n                    flexStyle = 'flex: 0 0 calc(' + pct + '% - ' + gapShare + 'px); min-width: 0;';\n                } else {\n                    flexStyle = 'flex: 0 0 ' + pct + '%; min-width: 0;';\n                }\n            } else {\n                flexStyle = 'flex: 1 1 0; min-width: 0;';\n            }\n        } else if (/(px|em|rem|vw|vh|ch|ex)$/i.test(str)) {\n            flexStyle = 'flex: 0 0 ' + str + '; min-width: 0;';\n        } else {\n            var numWidth = parseFloat(str);\n            if (!isNaN(numWidth) && numWidth > 0) {\n                flexStyle = 'flex: ' + numWidth + ' 1 0; min-width: 0;';\n            } else {\n                flexStyle = 'flex: 1 1 0; min-width: 0;';\n            }\n        }\n    }\n\n    var height = formatter.getFormat('height');\n    var heightStyle = '';\n    if (height !== null && height !== '' && height !== undefined) {\n        heightStyle = 'height: ' + height + (typeof height === 'number' ? 'px' : '') + ';';\n    }\n\n    var otherStyles = formatter.toStyleStringAll().replace(/width:\\s*[^;]+;?\\s*/gi, '').replace(/height:\\s*[^;]+;?\\s*/gi, '').trim();\n    var finalStyle = (flexStyle + ' ' + heightStyle + ' ' + otherStyles).trim();\n%><%- finalStyle %>\">\n    <% if (blocks && blocks.length) { blocks.forEach(function(block) { %>\n        <%- block %>\n    <% }); } %>\n</div>\n","P":"<p style=\"<%- formatter.toStyleStringAll() %>\" inline-edit=\"text\"><%- text %></p>\n","Link":"<a href=\"<%- url %>\"<% if (typeof target !== 'undefined' && target) { %> target=\"<%- target %>\"<% } %><% if (typeof rel !== 'undefined' && rel) { %> rel=\"<%- rel %>\"<% } %><% if (typeof title !== 'undefined' && title) { %> title=\"<%- title %>\"<% } %><% if (typeof download !== 'undefined' && download) { %> download=\"<%- download %>\"<% } %> style=\"display: block;<%- formatter.toStyleStringAll() %>\" inline-edit=\"text\"><%- text %></a>\n","H1":"<h1 inline-edit=\"text\" style=\"<%- formatter.toStyleStringAll() %>\"><%- text %></h1>","H2":"<h2 inline-edit=\"text\" style=\"<%- formatter.toStyleStringAll() %>\"><%- text %></h2>","H3":"<h3 inline-edit=\"text\" style=\"<%- formatter.toStyleStringAll() %>\"><%- text %></h3>","H4":"<h4 inline-edit=\"text\" style=\"<%- formatter.toStyleStringAll() %>\"><%- text %></h4>","H5":"<h5 inline-edit=\"text\" style=\"<%- formatter.toStyleStringAll() %>\"><%- text %></h5>","Heading":"<% if (type === 'h1') { %>\n    <h1 inline-edit=\"text\" style=\"<%- formatter.toStyleStringAll() %>\"><%- text %></h1>\n<% } else if (type === 'h2') { %>\n    <h2 inline-edit=\"text\" style=\"<%- formatter.toStyleStringAll() %>\"><%- text %></h2>\n<% } else if (type === 'h3') { %>\n    <h3 inline-edit=\"text\" style=\"<%- formatter.toStyleStringAll() %>\"><%- text %></h3>\n<% } else if (type === 'h4') { %>\n    <h4 inline-edit=\"text\" style=\"<%- formatter.toStyleStringAll() %>\"><%- text %></h4>\n<% } else if (type === 'h5') { %>\n    <h5 inline-edit=\"text\" style=\"<%- formatter.toStyleStringAll() %>\"><%- text %></h5>\n<% } else if (type === 'h6') { %>\n    <h6 inline-edit=\"text\" style=\"<%- formatter.toStyleStringAll() %>\"><%- text %></h6>\n<% } else { %>\n    <p inline-edit=\"text\" style=\"<%- formatter.toStyleStringAll() %>\"><%- text %></p>\n<% } %>\n","Image":"<div style=\"text-align: <%- formatter.getFormat(\"align\", \"center\") %>;\">\n    <% if (url) { %><a href=\"<%- url %>\"<% if (target) { %> target=\"<%- target %>\"<% } %> style=\"text-decoration:none;display:inline-block;\"><% } %>\n    <% if (crop && crop.enabled) { %>\n        <%\n            // Sanitize ratio integers (defensive — bad data should never reach here, but be safe)\n            var rx = (typeof crop.ratioX === 'number' && crop.ratioX >= 1) ? Math.round(crop.ratioX) : 3;\n            var ry = (typeof crop.ratioY === 'number' && crop.ratioY >= 1) ? Math.round(crop.ratioY) : 2;\n        %>\n        <div data-bjs-crop-frame=\"true\" style=\"display:inline-block;vertical-align:top;overflow:hidden;line-height:0;font-size:0;width:100%;aspect-ratio:<%= rx %>/<%= ry %>;\n            <%- formatter.toStyleStringAll(['padding_top','padding_right','padding_bottom','padding_left','width','height','max_width','max_height','min_width','min_height','border_top_style','border_top_width','border_top_color','border_right_style','border_right_width','border_right_color','border_bottom_style','border_bottom_width','border_bottom_color','border_left_width','border_left_style','border_left_color','border_radius','box_shadow']) %>\n        \">\n            <img src=\"<%= src %>\" alt=\"<%= alt %>\"\n                style=\"display:block;width:100%;height:100%;object-fit:cover;object-position:<%= crop.posX %>% <%= crop.posY %>%;transform:scale(<%= crop.zoom %>);transform-origin:<%= crop.posX %>% <%= crop.posY %>%;\n                <% if (effect) { %>filter: grayscale(<%= effect.grayscale %>%) sepia(<%= effect.sepia %>%) invert(<%= effect.invert %>%) blur(<%= effect.blur %>px) brightness(<%= effect.brightness %>%) contrast(<%= effect.contrast %>%) saturate(<%= effect.saturate %>%) hue-rotate(<%= effect.hueRotate %>deg) opacity(<%= effect.opacity %>%);<% } %>\n                \">\n        </div>\n    <% } else { %>\n        <img src=\"<%= src %>\" alt=\"<%= alt %>\"\n            style=\"display:inline-block;vertical-align:top;\n                <%- formatter.toStyleStringAll(['padding_top','padding_right','padding_bottom','padding_left','width','height','max_width','max_height','min_width','min_height','border_top_style','border_top_width','border_top_color','border_right_style','border_right_width','border_right_color','border_bottom_style','border_bottom_width','border_bottom_color','border_left_width','border_left_style','border_left_color','border_radius','box_shadow']) %>\n                <% if (effect) { %>filter: grayscale(<%= effect.grayscale %>%) sepia(<%= effect.sepia %>%) invert(<%= effect.invert %>%) blur(<%= effect.blur %>px) brightness(<%= effect.brightness %>%) contrast(<%= effect.contrast %>%) saturate(<%= effect.saturate %>%) hue-rotate(<%= effect.hueRotate %>deg) opacity(<%= effect.opacity %>%);<% } %>\n            \">\n    <% } %>\n    <% if (url) { %></a><% } %>\n</div>\n","Label":"<label style=\"<%- formatter.toStyleStringAll() %>\"><%- text %></label>","Button":"<div style=\"text-align: <%- formatter.getFormat('align', 'center') %>;\">\n    <a href=\"<%- url %>\"<% if (typeof target !== 'undefined' && target) { %> target=\"<%- target %>\"<% } %><% if (typeof rel !== 'undefined' && rel) { %> rel=\"<%- rel %>\"<% } %><% if (typeof title !== 'undefined' && title) { %> title=\"<%- title %>\"<% } %><% if (typeof download !== 'undefined' && download) { %> download=\"<%- download %>\"<% } %> style=\"<%- formatter.toStyleStringAll() %>box-sizing: border-box;\n        <% if (formatter && formatter.getFormat && formatter.getFormat('width')) { %>\n            display:block;\n            <% if (formatter.getFormat('align') === 'center') { %> margin-left: auto; margin-right: auto; <% } %>\n        <% } else { %>\n            display:inline-block;\n        <% } %>\n        text-decoration: none;\"\n        ><span inline-edit=\"text\"><%- text %></span></a>\n</div>\n","Alert":"<div style=\"<%- formatter.toStyleStringAll() %>\" inline-edit=\"text\"><%- text %></div>","RSS":"<div style=\"<%- formatter.toStyleStringAll() %>\">\n    <% if (!url) { %>\n        <!-- PLACEHOLDER STATE — Skeleton mirrors actual rendered layout -->\n        <div style=\"padding: 16px; background: #f8f9fb; border: 2px dashed #d0d5dd; border-radius: 12px;\">\n            <!-- Header -->\n            <div style=\"display: flex; align-items: center; gap: 10px; margin-bottom: 14px;\">\n                <svg style=\"width: 28px; height: 28px; flex-shrink: 0;\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 40 40\" fill=\"none\">\n                    <rect width=\"40\" height=\"40\" rx=\"10\" fill=\"#FF6B35\" opacity=\"0.15\"/>\n                    <path d=\"M14 26a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm12 0c0-6.627-5.373-12-12-12\" stroke=\"#FF6B35\" stroke-width=\"2.5\" stroke-linecap=\"round\" fill=\"none\"/>\n                    <path d=\"M30 26c0-9.941-8.059-18-18-18\" stroke=\"#FF6B35\" stroke-width=\"2.5\" stroke-linecap=\"round\" fill=\"none\"/>\n                </svg>\n                <div>\n                    <div style=\"font-weight: 700; font-size: 15px; color: #344054;\"><%- typeof I18n !== 'undefined' ? I18n.t('rss.configure_feed') : 'RSS Feed' %></div>\n                    <div style=\"font-size: 12px; color: #98a2b3;\"><%- typeof I18n !== 'undefined' ? I18n.t('rss.configure_feed_desc') : 'Add an RSS URL in the settings panel to display your feed here.' %></div>\n                </div>\n            </div>\n\n            <% if (style === 'modern') { %>\n                <!-- ═══ MODERN SKELETON — matches card layout exactly ═══ -->\n\n                <!-- Skeleton Card 1: image + title + desc + meta -->\n                <div style=\"border: 1px solid #e0e0e0; border-radius: 8px; margin-bottom: 16px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.05); background: #fff;\">\n                    <!-- Image placeholder: 200px height like real render -->\n                    <svg width=\"100%\" height=\"200\" viewBox=\"0 0 600 200\" preserveAspectRatio=\"xMidYMid slice\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <rect width=\"600\" height=\"200\" fill=\"#eaecf0\"/>\n                        <g transform=\"translate(260,70)\">\n                            <rect x=\"0\" y=\"0\" width=\"80\" height=\"60\" rx=\"8\" fill=\"#d0d5dd\"/>\n                            <polygon points=\"20,48 32,28 44,48\" fill=\"#c0c5cc\"/>\n                            <polygon points=\"36,48 50,34 64,48\" fill=\"#bcc1c8\"/>\n                            <circle cx=\"54\" cy=\"24\" r=\"8\" fill=\"#c0c5cc\"/>\n                        </g>\n                    </svg>\n                    <div style=\"padding: 16px;\">\n                        <!-- Title skeleton: h3 18px font-weight 600 -->\n                        <div style=\"height: 16px; width: 78%; background: #d0d5dd; border-radius: 4px; margin-bottom: 10px;\"></div>\n                        <!-- Description skeleton: 14px two lines -->\n                        <div style=\"height: 11px; width: 96%; background: #eaecf0; border-radius: 3px; margin-bottom: 6px;\"></div>\n                        <div style=\"height: 11px; width: 72%; background: #eaecf0; border-radius: 3px; margin-bottom: 12px;\"></div>\n                        <!-- Date + author meta: 12px -->\n                        <div style=\"display: flex; align-items: center; gap: 10px;\">\n                            <div style=\"display: flex; align-items: center; gap: 4px;\">\n                                <svg style=\"width: 12px; height: 12px; fill: #d0d5dd;\" viewBox=\"0 -960 960 960\"><path d=\"M580-240q-42 0-71-29t-29-71q0-42 29-71t71-29q42 0 71 29t29 71q0 42-29 71t-71 29ZM200-80q-33 0-56.5-23.5T120-160v-560q0-33 23.5-56.5T200-800h40v-80h80v80h320v-80h80v80h40q33 0 56.5 23.5T840-720v560q0 33-23.5 56.5T760-80H200Z\"/></svg>\n                                <div style=\"height: 8px; width: 90px; background: #eaecf0; border-radius: 3px;\"></div>\n                            </div>\n                            <div style=\"height: 8px; width: 60px; background: #eaecf0; border-radius: 3px;\"></div>\n                        </div>\n                    </div>\n                </div>\n\n                <!-- Skeleton Card 2: no image, title + desc + meta -->\n                <div style=\"border: 1px solid #e0e0e0; border-radius: 8px; margin-bottom: 16px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.05); background: #fff;\">\n                    <div style=\"padding: 16px;\">\n                        <div style=\"height: 16px; width: 62%; background: #d0d5dd; border-radius: 4px; margin-bottom: 10px;\"></div>\n                        <div style=\"height: 11px; width: 90%; background: #eaecf0; border-radius: 3px; margin-bottom: 6px;\"></div>\n                        <div style=\"height: 11px; width: 58%; background: #eaecf0; border-radius: 3px; margin-bottom: 12px;\"></div>\n                        <div style=\"display: flex; gap: 10px;\">\n                            <div style=\"height: 8px; width: 90px; background: #eaecf0; border-radius: 3px;\"></div>\n                        </div>\n                    </div>\n                </div>\n\n                <!-- Skeleton Card 3: no image, shorter -->\n                <div style=\"border: 1px solid #e0e0e0; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.05); background: #fff;\">\n                    <div style=\"padding: 16px;\">\n                        <div style=\"height: 16px; width: 70%; background: #d0d5dd; border-radius: 4px; margin-bottom: 10px;\"></div>\n                        <div style=\"height: 11px; width: 84%; background: #eaecf0; border-radius: 3px; margin-bottom: 6px;\"></div>\n                        <div style=\"height: 11px; width: 45%; background: #eaecf0; border-radius: 3px;\"></div>\n                    </div>\n                </div>\n\n            <% } else { %>\n                <!-- ═══ CLASSIC SKELETON — matches list layout exactly ═══ -->\n\n                <!-- Row 1: thumb 80x60 + title + desc + date -->\n                <div style=\"padding: 14px 0; border-bottom: 1px solid #eee;\">\n                    <div style=\"display: flex; gap: 14px; align-items: flex-start;\">\n                        <!-- Thumbnail placeholder: 80x60 rounded like real -->\n                        <svg width=\"80\" height=\"60\" viewBox=\"0 0 80 60\" xmlns=\"http://www.w3.org/2000/svg\" style=\"flex-shrink: 0; border-radius: 4px; overflow: hidden;\">\n                            <rect width=\"80\" height=\"60\" rx=\"4\" fill=\"#eaecf0\"/>\n                            <g transform=\"translate(24,14)\">\n                                <rect x=\"0\" y=\"0\" width=\"32\" height=\"32\" rx=\"4\" fill=\"#d0d5dd\"/>\n                                <polygon points=\"8,26 14,16 20,26\" fill=\"#c0c5cc\"/>\n                                <circle cx=\"22\" cy=\"14\" r=\"4\" fill=\"#c0c5cc\"/>\n                            </g>\n                        </svg>\n                        <div style=\"flex: 1; min-width: 0;\">\n                            <div style=\"height: 13px; width: 75%; background: #d0d5dd; border-radius: 3px; margin-bottom: 7px;\"></div>\n                            <div style=\"height: 10px; width: 95%; background: #eaecf0; border-radius: 3px; margin-bottom: 5px;\"></div>\n                            <div style=\"height: 10px; width: 60%; background: #eaecf0; border-radius: 3px; margin-bottom: 6px;\"></div>\n                            <div style=\"height: 8px; width: 100px; background: #eaecf0; border-radius: 3px;\"></div>\n                        </div>\n                    </div>\n                </div>\n\n                <!-- Row 2: thumb + title + desc + date -->\n                <div style=\"padding: 14px 0; border-bottom: 1px solid #eee;\">\n                    <div style=\"display: flex; gap: 14px; align-items: flex-start;\">\n                        <svg width=\"80\" height=\"60\" viewBox=\"0 0 80 60\" xmlns=\"http://www.w3.org/2000/svg\" style=\"flex-shrink: 0; border-radius: 4px;\">\n                            <rect width=\"80\" height=\"60\" rx=\"4\" fill=\"#eaecf0\"/>\n                            <g transform=\"translate(24,14)\"><rect width=\"32\" height=\"32\" rx=\"4\" fill=\"#d0d5dd\"/><polygon points=\"8,26 14,16 20,26\" fill=\"#c0c5cc\"/><circle cx=\"22\" cy=\"14\" r=\"4\" fill=\"#c0c5cc\"/></g>\n                        </svg>\n                        <div style=\"flex: 1; min-width: 0;\">\n                            <div style=\"height: 13px; width: 65%; background: #d0d5dd; border-radius: 3px; margin-bottom: 7px;\"></div>\n                            <div style=\"height: 10px; width: 88%; background: #eaecf0; border-radius: 3px; margin-bottom: 5px;\"></div>\n                            <div style=\"height: 10px; width: 50%; background: #eaecf0; border-radius: 3px; margin-bottom: 6px;\"></div>\n                            <div style=\"height: 8px; width: 80px; background: #eaecf0; border-radius: 3px;\"></div>\n                        </div>\n                    </div>\n                </div>\n\n                <!-- Row 3: thumb + title + desc -->\n                <div style=\"padding: 14px 0; border-bottom: 1px solid #eee;\">\n                    <div style=\"display: flex; gap: 14px; align-items: flex-start;\">\n                        <svg width=\"80\" height=\"60\" viewBox=\"0 0 80 60\" xmlns=\"http://www.w3.org/2000/svg\" style=\"flex-shrink: 0; border-radius: 4px;\">\n                            <rect width=\"80\" height=\"60\" rx=\"4\" fill=\"#eaecf0\"/><g transform=\"translate(24,14)\"><rect width=\"32\" height=\"32\" rx=\"4\" fill=\"#d0d5dd\"/><polygon points=\"8,26 14,16 20,26\" fill=\"#c0c5cc\"/><circle cx=\"22\" cy=\"14\" r=\"4\" fill=\"#c0c5cc\"/></g>\n                        </svg>\n                        <div style=\"flex: 1; min-width: 0;\">\n                            <div style=\"height: 13px; width: 72%; background: #d0d5dd; border-radius: 3px; margin-bottom: 7px;\"></div>\n                            <div style=\"height: 10px; width: 80%; background: #eaecf0; border-radius: 3px; margin-bottom: 5px;\"></div>\n                            <div style=\"height: 10px; width: 42%; background: #eaecf0; border-radius: 3px;\"></div>\n                        </div>\n                    </div>\n                </div>\n\n                <!-- Row 4: no thumb, title only -->\n                <div style=\"padding: 14px 0;\">\n                    <div style=\"display: flex; gap: 14px; align-items: flex-start;\">\n                        <svg width=\"80\" height=\"60\" viewBox=\"0 0 80 60\" xmlns=\"http://www.w3.org/2000/svg\" style=\"flex-shrink: 0; border-radius: 4px;\">\n                            <rect width=\"80\" height=\"60\" rx=\"4\" fill=\"#eaecf0\"/><g transform=\"translate(24,14)\"><rect width=\"32\" height=\"32\" rx=\"4\" fill=\"#d0d5dd\"/><polygon points=\"8,26 14,16 20,26\" fill=\"#c0c5cc\"/><circle cx=\"22\" cy=\"14\" r=\"4\" fill=\"#c0c5cc\"/></g>\n                        </svg>\n                        <div style=\"flex: 1; min-width: 0;\">\n                            <div style=\"height: 13px; width: 58%; background: #d0d5dd; border-radius: 3px; margin-bottom: 7px;\"></div>\n                            <div style=\"height: 10px; width: 75%; background: #eaecf0; border-radius: 3px; margin-bottom: 5px;\"></div>\n                            <div style=\"height: 8px; width: 90px; background: #eaecf0; border-radius: 3px;\"></div>\n                        </div>\n                    </div>\n                </div>\n\n            <% } %>\n        </div>\n\n    <% } else if (loading) { %>\n        <!-- LOADING STATE -->\n        <div style=\"display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px;\">\n            <div style=\"width: 40px; height: 40px; border: 3px solid #e0e0e0; border-top-color: #335fa0; border-radius: 50%; animation: rss-spin 0.8s linear infinite;\"></div>\n            <p style=\"margin: 12px 0 0 0; font-size: 14px; color: #999;\"><%- typeof I18n !== 'undefined' ? I18n.t('rss.loading') : 'Loading feed...' %></p>\n            <style>@keyframes rss-spin { to { transform: rotate(360deg); } }</style>\n        </div>\n\n    <% } else if (error) { %>\n        <!-- ERROR STATE -->\n        <div style=\"display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; text-align: center;\">\n            <svg style=\"height: 48px; width: 48px; fill: #dc3545;\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 -960 960 960\">\n                <path d=\"M480-280q17 0 28.5-11.5T520-320q0-17-11.5-28.5T480-360q-17 0-28.5 11.5T440-320q0 17 11.5 28.5T480-280Zm-40-160h80v-240h-80v240Zm40 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Z\"/>\n            </svg>\n            <p style=\"margin: 12px 0 0 0; font-size: 14px; color: #dc3545; font-weight: 500;\"><%- error %></p>\n            <p style=\"margin: 4px 0 0 0; font-size: 13px; color: #999;\"><%- typeof I18n !== 'undefined' ? I18n.t('rss.network_error') : 'Please check the URL and try again.' %></p>\n        </div>\n\n    <% } else if (items && items.length > 0) { %>\n\n        <% if (style === 'modern') { %>\n            <!-- MODERN CARD LAYOUT -->\n            <% items.forEach(function(item, index) { %>\n                <div style=\"border: 1px solid #e0e0e0; border-radius: 8px; margin-bottom: 16px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.05); background: #fff;\">\n                    <% if (show_image && item.image) { %>\n                        <div style=\"width: 100%; height: 200px; overflow: hidden;\">\n                            <img src=\"<%- item.image %>\" alt=\"\" style=\"width: 100%; height: 100%; object-fit: cover; display: block;\" />\n                        </div>\n                    <% } %>\n                    <div style=\"padding: 16px;\">\n                        <% if (show_title) { %>\n                            <% if (show_link && item.link) { %>\n                                <a href=\"<%- item.link %>\" target=\"_blank\" style=\"text-decoration: none; color: inherit;\">\n                                    <h3 style=\"margin: 0 0 8px 0; font-size: 18px; font-weight: 600; line-height: 1.3;\"><%- item.title %></h3>\n                                </a>\n                            <% } else { %>\n                                <h3 style=\"margin: 0 0 8px 0; font-size: 18px; font-weight: 600; line-height: 1.3;\"><%- item.title %></h3>\n                            <% } %>\n                        <% } %>\n                        <% if (show_desc && item.description) { %>\n                            <p style=\"margin: 0 0 10px 0; font-size: 14px; color: #555; line-height: 1.5;\"><%- truncate(item.description, content_length) %></p>\n                        <% } %>\n                        <div style=\"display: flex; align-items: center; flex-wrap: wrap; gap: 8px; font-size: 12px; color: #999;\">\n                            <% if (show_date && item.pubDate) { %>\n                                <span style=\"display: inline-flex; align-items: center; gap: 4px;\">\n                                    <svg style=\"width: 14px; height: 14px; fill: currentColor;\" viewBox=\"0 -960 960 960\"><path d=\"M580-240q-42 0-71-29t-29-71q0-42 29-71t71-29q42 0 71 29t29 71q0 42-29 71t-71 29ZM200-80q-33 0-56.5-23.5T120-160v-560q0-33 23.5-56.5T200-800h40v-80h80v80h320v-80h80v80h40q33 0 56.5 23.5T840-720v560q0 33-23.5 56.5T760-80H200Z\"/></svg>\n                                    <%- item.pubDate %>\n                                </span>\n                            <% } %>\n                            <% if (show_author && item.author) { %>\n                                <span><%- typeof I18n !== 'undefined' ? I18n.t('rss.by') : 'by' %> <%- item.author %></span>\n                            <% } %>\n                        </div>\n                        <% if (show_link && item.link && !show_title) { %>\n                            <a href=\"<%- item.link %>\" target=\"_blank\" style=\"display: inline-block; margin-top: 8px; font-size: 13px; color: #335fa0; text-decoration: none; font-weight: 500;\"><%- typeof I18n !== 'undefined' ? I18n.t('rss.read_more') : 'Read more' %> &rarr;</a>\n                        <% } %>\n                    </div>\n                </div>\n            <% }); %>\n\n        <% } else { %>\n            <!-- CLASSIC LIST LAYOUT -->\n            <% items.forEach(function(item, index) { %>\n                <div style=\"padding: 14px 0; <%= index < items.length - 1 ? 'border-bottom: 1px solid #eee;' : '' %>\">\n                    <div style=\"display: flex; gap: 14px; align-items: flex-start;\">\n                        <% if (show_image && item.image) { %>\n                            <img src=\"<%- item.image %>\" alt=\"\" style=\"width: 80px; height: 60px; object-fit: cover; border-radius: 4px; flex-shrink: 0;\" />\n                        <% } %>\n                        <div style=\"flex: 1; min-width: 0;\">\n                            <% if (show_title) { %>\n                                <% if (show_link && item.link) { %>\n                                    <a href=\"<%- item.link %>\" target=\"_blank\" style=\"font-weight: 600; font-size: 15px; color: #333; text-decoration: none; line-height: 1.3; display: block;\"><%- item.title %></a>\n                                <% } else { %>\n                                    <div style=\"font-weight: 600; font-size: 15px; color: #333; line-height: 1.3;\"><%- item.title %></div>\n                                <% } %>\n                            <% } %>\n                            <% if (show_desc && item.description) { %>\n                                <p style=\"margin: 4px 0 0 0; font-size: 13px; color: #666; line-height: 1.4;\"><%- truncate(item.description, content_length) %></p>\n                            <% } %>\n                            <div style=\"margin-top: 4px; font-size: 12px; color: #999;\">\n                                <% if (show_date && item.pubDate) { %>\n                                    <span><%- item.pubDate %></span>\n                                <% } %>\n                                <% if (show_author && item.author) { %>\n                                    <% if (show_date && item.pubDate) { %> &middot; <% } %>\n                                    <span><%- item.author %></span>\n                                <% } %>\n                            </div>\n                        </div>\n                    </div>\n                </div>\n            <% }); %>\n        <% } %>\n\n    <% } else { %>\n        <!-- EMPTY STATE -->\n        <div style=\"display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; text-align: center;\">\n            <svg style=\"height: 48px; width: 48px; fill: #999;\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 -960 960 960\">\n                <path d=\"M480-280q17 0 28.5-11.5T520-320q0-17-11.5-28.5T480-360q-17 0-28.5 11.5T440-320q0 17 11.5 28.5T480-280Zm-40-160h80v-240h-80v240Zm40 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Z\"/>\n            </svg>\n            <p style=\"margin: 12px 0 0 0; font-size: 14px; color: #999;\"><%- typeof I18n !== 'undefined' ? I18n.t('rss.no_items') : 'No items found in this feed.' %></p>\n        </div>\n    <% } %>\n</div>\n","Youtube":"<div style=\"display: flex; align-items: center; justify-content: <%- formatter.getFormat('align', 'center') %>;\">\n    <% if (!youtubeID) { %>\n        <%\n            var placeholderStyle = formatter.toStyleStringAll([\n                'width','height',\n                'border_top_style','border_top_width','border_top_color',\n                'border_right_style','border_right_width','border_right_color',\n                'border_bottom_style','border_bottom_width','border_bottom_color',\n                'border_left_style','border_left_width','border_left_color',\n                'border_radius'\n            ]);\n        %>\n        <div class=\"bjs-video-placeholder\" style=\"display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 2.25rem 1rem; text-align: center; box-sizing: border-box; max-width: 100%; min-width: 240px; min-height: 180px; <%- placeholderStyle %> border: 1px dashed #c7ccd4; background: #f6f7f9; color: #5a6270;\">\n            <svg width=\"96\" height=\"72\" viewBox=\"0 0 96 72\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\" aria-hidden=\"true\" style=\"flex: 0 0 auto;\">\n                <rect x=\"1\" y=\"1\" width=\"94\" height=\"62\" rx=\"10\" fill=\"#ffffff\" stroke=\"#c7ccd4\" stroke-width=\"1.5\"/>\n                <path d=\"M40 22 L62 32 L40 42 Z\" fill=\"#EA333D\" opacity=\"0.9\"/>\n                <line x1=\"10\" y1=\"68\" x2=\"86\" y2=\"68\" stroke=\"#c7ccd4\" stroke-width=\"1.5\" stroke-linecap=\"round\"/>\n            </svg>\n            <div>\n                <h4 style=\"margin: 0 0 4px; font-weight: 600; font-size: 15px; color: #1f2937;\" inline-edit=\"text\">No YouTube video yet</h4>\n                <p style=\"margin: 0; font-size: 13px; line-height: 1.5;\" inline-edit=\"text\">Paste a YouTube URL (youtube.com/watch?v=… or youtu.be/…) in the right panel to embed it here.</p>\n            </div>\n        </div>\n    <% } else { %>\n        <iframe\n            style=\"display: block; max-width: 100%; <%- formatter.toStyleStringAll(['width','height','border_top_style','border_top_width','border_top_color','border_right_style','border_right_width','border_right_color','border_bottom_style','border_bottom_width','border_bottom_color','border_left_style','border_left_width','border_left_color','border_radius']) %>\"\n            src=\"https://www.youtube.com/embed/<%= youtubeID %>\"\n            title=\"YouTube video player\"\n            frameborder=\"0\"\n            allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\"\n            referrerpolicy=\"strict-origin-when-cross-origin\"\n            allowfullscreen></iframe>\n    <% } %>\n</div>\n","Menu":"<%\nvar orientation = formatter.getFormat('orientation', 'horizontal');\nvar flexDirection = orientation === 'vertical' ? 'column' : 'row';\nvar separatorPaddingProp = orientation === 'vertical' ? 'padding-top' : 'padding-left';\nvar separatorPaddingProp2 = orientation === 'vertical' ? 'padding-bottom' : 'padding-right';\nvar textAlign = formatter.getFormat('text_align');\nvar alignItems = 'center';\nif (orientation === 'vertical' && textAlign) {\n    alignItems = textAlign === 'left' ? 'flex-start' : (textAlign === 'right' ? 'flex-end' : 'center');\n}\n// Only dump the text/style-relevant formats onto the <a>. Without this\n// filter, non-style keys (align, orientation, separator, separator_gap)\n// get kebab-cased into the style attribute as invalid CSS like\n// `align: center; orientation: horizontal;` — benign visually but\n// confusing when inspecting and makes audits harder to reason about.\nvar anchorStyles = formatter.toStyleStringAll(['font_family', 'font_weight', 'font_size', 'text_color']);\n%>\n<div style=\"display: flex; justify-content: <%- formatter.getFormat('align', 'center') %>;\">\n    <ul style=\"display: flex; gap: <%- menu_gap %>px; flex-direction: <%- flexDirection %>; list-style: none; padding: 0; align-items: <%- alignItems %>; margin: 0;\">\n        <% items.forEach(function(item, idx) { %>\n            <li>\n                <a href=\"<%= item.url %>\" style=\"text-decoration: none; <%- anchorStyles %>\">\n                    <%= item.text %>\n                </a>\n            </li>\n\n            <% if (idx < items.length - 1 && formatter && formatter.getFormat && formatter.getFormat('separator')) { %>\n                <li class=\"menu-separator\" style=\"display:flex; align-items:center; <%- separatorPaddingProp %>:<%- formatter.getFormat('separator_gap', menu_gap) %>px; <%- separatorPaddingProp2 %>:<%- formatter.getFormat('separator_gap', menu_gap) %>px;\">\n                    <span style=\"color: <%- formatter.getFormat('text_color') || '#000' %>;\"> <%- formatter.getFormat('separator') %> </span>\n                </li>\n            <% } %>\n\n        <% }); %>\n    </ul>\n</div>","HTML":"<%- html %>","Video":"<div style=\"display: flex; align-items: center; justify-content: <%- formatter.getFormat('align', 'center') %>;\">\n    <% if (!src) { %>\n        <%\n            // Apply the same width/height the real <video> would use, so the\n            // user can visualize how large the video placeholder will render\n            // while they tweak the sliders. Fall back to `auto` when unset so\n            // the empty state stays compact on first drop.\n            var placeholderStyle = formatter.toStyleStringAll([\n                'width','height',\n                'border_top_style','border_top_width','border_top_color',\n                'border_right_style','border_right_width','border_right_color',\n                'border_bottom_style','border_bottom_width','border_bottom_color',\n                'border_left_style','border_left_width','border_left_color',\n                'border_radius'\n            ]);\n        %>\n        <div class=\"bjs-video-placeholder\" style=\"display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 2.25rem 1rem; text-align: center; box-sizing: border-box; max-width: 100%; min-width: 240px; min-height: 180px; <%- placeholderStyle %> border: 1px dashed #c7ccd4; background: #f6f7f9; color: #5a6270;\">\n            <svg width=\"96\" height=\"72\" viewBox=\"0 0 96 72\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\" aria-hidden=\"true\" style=\"flex: 0 0 auto;\">\n                <rect x=\"1\" y=\"1\" width=\"94\" height=\"62\" rx=\"6\" fill=\"#ffffff\" stroke=\"#c7ccd4\" stroke-width=\"1.5\"/>\n                <path d=\"M40 22 L62 32 L40 42 Z\" fill=\"#3b82f6\" opacity=\"0.85\"/>\n                <line x1=\"10\" y1=\"68\" x2=\"86\" y2=\"68\" stroke=\"#c7ccd4\" stroke-width=\"1.5\" stroke-linecap=\"round\"/>\n            </svg>\n            <div>\n                <h4 style=\"margin: 0 0 4px; font-weight: 600; font-size: 15px; color: #1f2937;\" inline-edit=\"text\">No video yet</h4>\n                <p style=\"margin: 0; font-size: 13px; line-height: 1.5;\" inline-edit=\"text\">Paste a direct video URL (.mp4, .webm, .ogg) in the right panel to preview it here.</p>\n            </div>\n        </div>\n    <% } else { %>\n        <div style=\"position: relative; max-width: 100%;\">\n            <video\n                style=\"display: block; max-width: 100%; <%- formatter.toStyleStringAll(['width','height','border_top_style','border_top_width','border_top_color','border_right_style','border_right_width','border_right_color','border_bottom_style','border_bottom_width','border_bottom_color','border_left_style','border_left_width','border_left_color','border_radius']) %> box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);\"\n                src=\"<%= src %>\"\n                autoplay=\"[[AUTOPLAY]]\" loop=\"[[LOOP]]\" muted=\"[[MUTED]]\" playsinline=\"[[PLAYSINLINE]]\" controls=\"[[CONTROLS]]\"\n                onerror=\"var e=this.nextElementSibling; if(e){e.hidden=false;} this.style.visibility='hidden';\"\n            >\n                <source src=\"<%= src %>\" type=\"video/mp4\">\n                <source src=\"[[SRC_OGG]]\" type=\"video/ogg\">\n                <source src=\"[[SRC_WEBM]]\" type=\"video/webm\">\n                Your browser does not support the video tag.\n            </video>\n            <div hidden style=\"position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 16px; text-align: center; border: 1px dashed #e0b4b4; border-radius: 8px; background: #fff5f5; color: #b04343; font-size: 13px; line-height: 1.5;\">\n                <span>The video URL couldn't be played. The file may be missing, blocked by CORS, or not a supported format (mp4/webm/ogg).</span>\n            </div>\n        </div>\n    <% } %>\n</div>\n","SocialIcons":"<div width=\"100%\" builder-element=\"BlockElement\">\n    <div style=\"text-align: <%- align %>;\">\n        <div class=\"\" style=\"display: inline-block;\">\n            <div builder-element=\"IconsContainerElement\"\n                style=\"display: flex; gap: <%= gap %>px; <%- formatter.toStyleStringAll() %>\">\n                <% items.forEach(function(item) { %>\n                    <div style=\"display: inline-block;\">\n                        <a builder-element=\"IconLinkElement\" data-value=\"twitter\" href=\"<%= item.link %>\"\n                            class=\"\">\n                            <span class=\"social-icon\" inline-edit=\"text\">\n                                <img alt=\"<%= item.label %>\" src=\"<%= item.image_url %>\" width=\"<%- size %>px\" height=\"<%- size %>px\">\n                            </span>\n                        </a>\n                    </div>\n                <% }); %>\n            </div>\n        </div>\n    </div>\n</div>","TextInput":"<div<% if (formatter && formatter.getFormat && formatter.getFormat('align')) { %> style=\"text-align:<%- formatter.getFormat('align') %>\"<% } %>>\n    <input type=\"<%- type %>\" name=\"<%- name %>\" value=\"<%- value %>\"\n    <%- required ? 'required' : '' %>\n    placeholder=\"<%- placeholder %>\" style=\"<%- formatter.toStyleStringAll() %>box-sizing: border-box;\n        <% if (formatter && formatter.getFormat && formatter.getFormat('width')) { %>\n            display:block;\n            <% if (formatter.getFormat('align') === 'center') { %> margin-left: auto; margin-right: auto; <% } %>\n        <% } else { %>\n            display:block; width:100%;\n        <% } %>\" />\n</div>","Select":"<div style=\"width: 100%;\">\n    <select\n        name=\"<%- inputName || 'select_field' %>\"\n        style=\"<%- formatter.toStyleStringAll() %>box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 12px; color: #0f172a; background-color: <%- formatter.getFormat('background_color', '#ffffff') %>; <% if (!formatter.getFormat('width')) { %>width: 100%;<% } %> <% if (!formatter.getFormat('height')) { %>min-height: 44px;<% } %>\"\n    >\n        <% items.forEach(function(item, index) { %>\n            <option value=\"<%- item.value %>\" <%= index === 0 ? 'selected' : '' %>><%- item.label %></option>\n        <% }); %>\n    </select>\n</div>\n","Radio":"<div style=\"display: grid; gap: 10px;\">\n    <% var groupName = inputName || 'radio_group'; %>\n    <% items.forEach(function(item, index) { %>\n        <% var optionId = groupName + '_' + index; %>\n        <label for=\"<%- optionId %>\" style=\"display: flex; align-items: flex-start; gap: 10px; cursor: pointer; color: #0f172a;\">\n            <input id=\"<%- optionId %>\" type=\"radio\" name=\"<%- groupName %>\" value=\"<%- item.value %>\" <%= index === 0 ? 'checked' : '' %> style=\"margin-top: 3px;\" />\n            <span><%- item.label %></span>\n        </label>\n    <% }); %>\n</div>\n","Checkbox":"<div style=\"display: grid; gap: 10px;\">\n    <% var groupName = inputName || 'checkbox_group'; %>\n    <% items.forEach(function(item, index) { %>\n        <% var optionId = groupName + '_' + index; %>\n        <label for=\"<%- optionId %>\" style=\"display: flex; align-items: flex-start; gap: 10px; cursor: pointer; color: #0f172a;\">\n            <input id=\"<%- optionId %>\" type=\"checkbox\" name=\"<%- groupName %>[]\" value=\"<%- item.value %>\" style=\"margin-top: 3px;\" />\n            <span><%- item.label %></span>\n        </label>\n    <% }); %>\n</div>\n","List":"<ul style=\"list-style: disc; padding-left: 1.5em; margin: 0;\">\n<% items.forEach(function(item) { %>\n    <li style=\"margin-bottom: 0.4em;\"><%- item %></li>\n<% }); %>\n</ul>\n","PricingCards":"<div data-pricing-cards-style=\"<%= (typeof style === 'string' && style) ? style : 'highlighted' %>\" style=\"<%- formatter.toStyleStringAll() %> display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); width: 100%; gap: <%= cell_gap %>px; align-items: <%= align_items || 'stretch' %>;\">\n    <% cells.forEach(function(cell) { %>\n        <%- cell %>\n    <% }); %>\n</div>\n","PricingCard":"<%\n// PricingCard.template.html — 13-style card wrapper.\n// Context: style, highlighted, badge, icon, accent, renderIcon(name,size,color)\n//          cell_index, cell_count (0-based + total, used by banded/tier)\n//          + blocks, formatter, vertical_align, cell_gap, cell_count\n\nvar _style = (typeof style === 'string' && style) ? style : 'highlighted';\nvar _highlighted = !!highlighted;\nvar _badge = (typeof badge === 'string' && badge.trim()) ? badge.trim() : '';\nvar _icon = (typeof icon === 'string' && icon.trim()) ? icon.trim() : '';\nvar _accent = (typeof accent === 'string' && accent.trim()) ? accent.trim() : '#ffcc2a';\nvar _idx = (typeof cell_index === 'number' && cell_index >= 0) ? cell_index : 0;\nvar _count = (typeof cell_count === 'number' && cell_count > 0) ? cell_count : 1;\nvar _isFirst = (_idx === 0);\nvar _isLast = (_idx === _count - 1);\n\n// Inline SVG icon renderer — falls back to emoji passthrough for non-ASCII\n// input and first-letter circle for unknown names. See pricingCardIcons.js.\nvar _renderIcon = (typeof renderIcon === 'function') ? renderIcon : function () { return ''; };\n\nvar TOKENS = {\n    minimal:     { radius: '12px', pad: '24px 20px', shadow: '0 1px 2px rgba(15,23,42,0.04)', border: '1px solid #e5e7eb',                   bg: '#ffffff',                         color: '#111827' },\n    highlighted: { radius: '12px', pad: '24px 20px', shadow: '0 1px 2px rgba(15,23,42,0.04)', border: '1px solid #e5e7eb',                   bg: '#ffffff',                         color: '#111827' },\n    featured:    { radius: '18px', pad: '36px 20px 28px', shadow: '0 16px 40px rgba(15,23,42,0.08)', border: '1px solid #f0d0c8',             bg: '#ffffff',                         color: '#111827' },\n    compact:     { radius: '10px', pad: '18px 16px', shadow: '0 1px 2px rgba(15,23,42,0.04)', border: '1px solid #d1d5db',                   bg: '#ffffff',                         color: '#111827' },\n    glass:       { radius: '16px', pad: '28px 22px', shadow: '0 8px 32px rgba(49,46,129,0.12)', border: '1px solid rgba(255,255,255,0.5)',  bg: 'rgba(255,255,255,0.65)',          color: '#312e81' },\n    pill:        { radius: '24px', pad: '32px 22px', shadow: '0 2px 8px rgba(0,0,0,0.04)', border: '0',                                      bg: '#ffffff',                         color: '#111827' },\n    neon:        { radius: '10px', pad: '24px 20px', shadow: '0 0 24px rgba(0,229,255,0.25)', border: '1px solid rgba(0,229,255,0.35)',    bg: '#14141c',                         color: '#e5e7eb' },\n    brutalist:   { radius: '0',    pad: '24px 20px', shadow: '8px 8px 0 0 #000', border: '2px solid #000',                                     bg: '#ffffff',                         color: '#111827' },\n    editorial:   { radius: '2px',  pad: '28px 22px', shadow: '0 2px 8px rgba(28,25,23,0.06)', border: '1px solid #d4d4aa',                  bg: '#ffffff',                         color: '#1c1917' },\n    gradient:    { radius: '14px', pad: '28px 22px', shadow: '0 12px 32px rgba(0,0,0,0.12)', border: '0',                                   bg: 'linear-gradient(135deg,' + _accent + ',' + _accent + 'cc)', color: '#ffffff' },\n    // Joined table — border-radius computed dynamically below (only outer\n    // corners round, inner corners sharp). Inner vertical border creates\n    // the divider between cells. Highlighted card gets a light-blue top\n    // strip for the Mailchimp \"Best value\" label.\n    banded:      { radius: 'DYN',  pad: '32px 28px', shadow: '0 1px 2px rgba(15,23,42,0.05)', border: 'DYN',                                 bg: '#ffffff',                         color: '#1c1917' },\n    // Staircase elevation — each card sits at a different margin-top so\n    // the row of cards looks like steps climbing from index 0 → N-1.\n    // Shadow grows with index to reinforce the climb.\n    tier:        { radius: '16px', pad: '28px 22px', shadow: 'DYN',                           border: '1px solid #e0e7ff',                  bg: '#ffffff',                         color: '#1e1b4b' },\n    // Neumorphism — soft gray surface with dual-direction shadow (outer\n    // bright + outer dark) for the \"pushed out\" look. Highlighted flips\n    // to inset for \"pushed in\".\n    neo:         { radius: '22px', pad: '32px 26px', shadow: 'DYN',                           border: '0',                                   bg: '#e9edf3',                         color: '#1e293b' }\n};\nvar T = TOKENS[_style] || TOKENS.highlighted;\n\n// Resolve dynamic tokens for banded / tier / neo.\nvar _extraStyles = '';  // injected into rootStyle list below (banded divider)\nif (_style === 'banded') {\n    // Outer radius on first/last card only, 0 on inner corners. Middle\n    // cards have no radius at all. Highlighted override handled\n    // separately below.\n    var _bRadius = '0';\n    if (_count === 1) _bRadius = '12px';\n    else if (_isFirst) _bRadius = '12px 0 0 12px';\n    else if (_isLast)  _bRadius = '0 12px 12px 0';\n    T = Object.assign({}, T, {\n        radius: _bRadius,\n        border: '1px solid #e5e7eb',\n    });\n    // Drop the right border on non-last cards so the divider reads as a\n    // single shared line between joined cells (otherwise adjacent 1px\n    // borders stack visually to 2px). Done via separate extra style so\n    // it composes cleanly with the cardBorder highlighted override below.\n    if (!_isLast) _extraStyles += ' border-right: 0;';\n}\nif (_style === 'tier') {\n    // Shadow ramp: index 0 = smallest, last = largest. Keeps the base\n    // shadow subtle so stacks of 4 cards don't feel crushed.\n    var _shadowStep = Math.round((_idx / Math.max(1, _count - 1)) * 20);\n    T = Object.assign({}, T, {\n        shadow: '0 ' + (4 + _shadowStep) + 'px ' + (16 + _shadowStep * 2) + 'px rgba(79,70,229,' + (0.06 + _idx * 0.02) + ')',\n    });\n}\nif (_style === 'neo') {\n    // Dual-direction neumorphism — LIGHT shadow on top-left, DARK on\n    // bottom-right. Values tuned against #e9edf3 card bg.\n    T = Object.assign({}, T, {\n        shadow: _highlighted\n            ? 'inset 8px 8px 16px #c7cdd6, inset -8px -8px 16px #ffffff'\n            : '10px 10px 24px #c7cdd6, -10px -10px 24px #ffffff',\n    });\n}\n\nvar cardBorder = _highlighted ? ('2px solid ' + _accent) : T.border;\nvar cardShadow = _highlighted ? ('0 16px 36px ' + _accent + '33, ' + T.shadow) : T.shadow;\n// No translateY on root — it broke featured ribbon's translateX anchor and\n// caused painted shadow/border to overlap across style switches. Apply lift\n// via a top margin-bottom offset that doesn't stack-context collide.\nvar cardMarginTop = (_style === 'highlighted' && _highlighted) ? '-6px' : '0';\nvar cardMarginBottom = (_style === 'highlighted' && _highlighted) ? '6px' : '0';\nif (_style === 'brutalist' && _highlighted) {\n    cardBorder = '3px solid #000';\n    cardShadow = '10px 10px 0 0 ' + _accent;\n    // Flood the highlighted brutalist card with accent so white-on-white\n    // text doesn't disappear — also matches the genre (solid accent =\n    // \"the loud one\"). Override rootStyle's background token further down.\n    T = Object.assign({}, T, { bg: _accent, color: '#ffffff' });\n}\nif (_style === 'neon' && _highlighted) {\n    cardBorder = '2px solid ' + _accent;\n    cardShadow = '0 0 36px ' + _accent + '66, inset 0 0 16px ' + _accent + '22';\n}\nif (_style === 'banded' && _highlighted) {\n    // Subtle tinted background on highlighted Mailchimp-style cell;\n    // card keeps its joined-row appearance (don't lift or add stroke\n    // that would break the table).\n    cardBorder = T.border;\n    cardShadow = T.shadow;\n    T = Object.assign({}, T, { bg: '#fffdf0' });\n}\nif (_style === 'tier') {\n    // Staircase offset — first card sits LOWEST (largest top margin),\n    // last card sits HIGHEST (zero top margin). Climbs left→right.\n    var _tierStep = 12;\n    var _fromBottom = (_count - 1 - _idx);\n    cardMarginTop = (_fromBottom * _tierStep) + 'px';\n    cardMarginBottom = (_idx * _tierStep) + 'px';\n    if (_highlighted) {\n        cardBorder = '2px solid ' + _accent;\n        cardShadow = '0 18px 40px ' + _accent + '33';\n    }\n}\nif (_style === 'neo' && _highlighted) {\n    // Already set T.shadow to inset above; also add a subtle accent ring.\n    cardBorder = '2px solid ' + _accent + '66';\n}\n\n// Width via grid — parent uses display: grid, so cells stretch naturally. Keep\n// height override for vertical sizing only.\nvar height = formatter.getFormat('height');\nvar heightStyle = '';\nif (height !== null && height !== '' && height !== undefined) {\n    heightStyle = 'height: ' + height + (typeof height === 'number' ? 'px' : '') + ';';\n}\n\n// Other formats (user-customized bg/border/padding on the cell formatter itself)\nvar otherStyles = formatter.toStyleStringAll().replace(/width:\\s*[^;]+;?\\s*/gi, '').replace(/height:\\s*[^;]+;?\\s*/gi, '').trim();\n\nvar rootStyle = [\n    'position: relative',\n    'display: flex',\n    'flex-direction: column',\n    'gap: 8px',\n    'min-height: 100%',\n    'box-sizing: border-box',\n    'border: ' + cardBorder,\n    'border-radius: ' + T.radius,\n    'padding: ' + T.pad,\n    'background: ' + T.bg,\n    'box-shadow: ' + cardShadow,\n    'margin-top: ' + cardMarginTop,\n    'margin-bottom: ' + cardMarginBottom,\n    'color: ' + T.color,\n    'transition: box-shadow 150ms ease, border-color 150ms ease',\n    'overflow: visible',\n    heightStyle,\n    otherStyles\n].filter(function (s) { return s && s.length; }).join('; ') + ';' + _extraStyles;\n\nif (_style === 'glass') {\n    rootStyle += ' backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);';\n}\n\n// ---- BADGE ----\nvar badgeHtml = '';\nif (_badge) {\n    if (_style === 'featured') {\n        badgeHtml = '<div data-role=\"pricing-badge\" data-variant=\"ribbon\" style=\"position: absolute; top: -12px; left: 50%; transform: translateX(-50%); background: ' + _accent + '; color: #ffffff; font-size: 10px; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; white-space: nowrap; padding: 5px 12px; border-radius: 999px; white-space: nowrap; box-shadow: 0 6px 14px ' + _accent + '40; z-index: 2;\">' + _badge + '</div>';\n    } else if (_style === 'highlighted' && _highlighted) {\n        badgeHtml = '<div data-role=\"pricing-badge\" data-variant=\"band\" style=\"margin: -' + T.pad.split(' ')[0] + ' -' + T.pad.split(' ')[1] + ' 4px; background: ' + _accent + '; color: #1a1a1a; font-size: 11px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; white-space: nowrap; padding: 7px 10px; text-align: center; border-radius: ' + T.radius + ' ' + T.radius + ' 0 0;\">' + _badge + '</div>';\n    } else if (_style === 'brutalist') {\n        badgeHtml = '<div data-role=\"pricing-badge\" data-variant=\"blocky\" style=\"background: #000; color: #fff; font-weight: 900; font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase; white-space: nowrap; padding: 4px 8px; align-self: flex-start;\">' + _badge + '</div>';\n    } else if (_style === 'neon') {\n        badgeHtml = '<div data-role=\"pricing-badge\" data-variant=\"outline\" style=\"background: transparent; color: ' + _accent + '; font-weight: 700; font-size: 9px; letter-spacing: 0.18em; text-transform: uppercase; white-space: nowrap; border: 1px solid ' + _accent + '; padding: 2px 8px; border-radius: 999px; align-self: flex-start; box-shadow: 0 0 12px ' + _accent + '44;\">' + _badge + '</div>';\n    } else if (_style === 'editorial') {\n        badgeHtml = '<div data-role=\"pricing-badge\" data-variant=\"inline-serif\" style=\"color: ' + _accent + '; font-family: Georgia, serif; font-size: 11px; font-weight: 700; font-style: italic; letter-spacing: 0.05em; align-self: flex-start; padding: 2px 0; border-bottom: 1px solid ' + _accent + ';\">' + _badge + '</div>';\n    } else if (_style === 'gradient') {\n        badgeHtml = '<div data-role=\"pricing-badge\" data-variant=\"frost\" style=\"background: rgba(255,255,255,0.22); color: #ffffff; font-size: 10px; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; white-space: nowrap; padding: 3px 9px; border-radius: 999px; align-self: flex-start;\">' + _badge + '</div>';\n    } else if (_style === 'pill') {\n        badgeHtml = '<div data-role=\"pricing-badge\" data-variant=\"center-pill\" style=\"background: ' + _accent + '; color: #ffffff; font-size: 10px; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; white-space: nowrap; padding: 3px 9px; border-radius: 999px; align-self: center;\">' + _badge + '</div>';\n    } else if (_style === 'glass') {\n        badgeHtml = '<div data-role=\"pricing-badge\" data-variant=\"glass-chip\" style=\"background: ' + _accent + '22; color: ' + _accent + '; font-size: 10px; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; white-space: nowrap; padding: 3px 9px; border-radius: 999px; align-self: flex-start;\">' + _badge + '</div>';\n    } else if (_style === 'banded') {\n        // Mailchimp-style \"Best value\" label — centered, underline, soft\n        // blue tint strip that sits flush on top of the card (negative\n        // margin matches the card's top padding so the strip fills the\n        // full card width at the top edge).\n        badgeHtml = '<div data-role=\"pricing-badge\" data-variant=\"banded-strip\" style=\"margin: -' + T.pad.split(' ')[0] + ' -' + T.pad.split(' ')[1] + ' 18px; background: #e8f1ff; color: #1e3a8a; font-family: Georgia, serif; font-style: italic; font-size: 13px; text-align: center; padding: 8px 12px; text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 3px;\">' + _badge + '</div>';\n    } else if (_style === 'tier') {\n        // Tier badge — bold uppercase pill in the accent color, sits\n        // above the heading. Quiet unless highlighted.\n        badgeHtml = '<div data-role=\"pricing-badge\" data-variant=\"tier-pill\" style=\"background: ' + _accent + '; color: #ffffff; font-size: 10px; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; white-space: nowrap; padding: 4px 10px; border-radius: 6px; align-self: flex-start; box-shadow: 0 4px 10px ' + _accent + '55;\">' + _badge + '</div>';\n    } else if (_style === 'neo') {\n        // Neumorphic chip — inset soft shadow, no fill, accent text.\n        badgeHtml = '<div data-role=\"pricing-badge\" data-variant=\"neo-chip\" style=\"background: #eef1f6; color: ' + _accent + '; font-size: 10px; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; white-space: nowrap; padding: 5px 12px; border-radius: 999px; align-self: flex-start; box-shadow: inset 2px 2px 4px #c7cdd6, inset -2px -2px 4px #ffffff;\">' + _badge + '</div>';\n    } else {\n        // minimal + compact fallback\n        badgeHtml = '<div data-role=\"pricing-badge\" data-variant=\"chip\" style=\"display: inline-flex; align-self: flex-start; background: ' + _accent + '22; color: ' + _accent + '; font-size: 10px; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; white-space: nowrap; padding: 3px 9px; border-radius: 999px;\">' + _badge + '</div>';\n    }\n}\n\n// ---- ICON (inline SVG via registry) ----\nvar iconHtml = '';\nif (_icon) {\n    if (_style === 'featured') {\n        iconHtml = '<div data-role=\"pricing-icon\" data-variant=\"circle\" style=\"align-self: center; width: 52px; height: 52px; border-radius: 50%; background: ' + _accent + '1c; color: ' + _accent + '; display: inline-flex; align-items: center; justify-content: center; margin: 2px 0 4px;\">' + _renderIcon(_icon, 26, _accent) + '</div>';\n    } else if (_style === 'pill') {\n        iconHtml = '<div data-role=\"pricing-icon\" data-variant=\"stamp\" style=\"align-self: center; color: ' + _accent + '; margin-bottom: 6px;\">' + _renderIcon(_icon, 36, _accent) + '</div>';\n    } else if (_style === 'compact') {\n        iconHtml = ''; // compact hides icon\n    } else if (_style === 'gradient') {\n        iconHtml = '<div data-role=\"pricing-icon\" data-variant=\"overlay\" style=\"align-self: flex-start; color: #ffffff; margin-bottom: 2px;\">' + _renderIcon(_icon, 22, '#ffffff') + '</div>';\n    } else if (_style === 'neon') {\n        iconHtml = '<div data-role=\"pricing-icon\" data-variant=\"glow\" style=\"align-self: flex-start; color: ' + _accent + '; filter: drop-shadow(0 0 6px ' + _accent + '88); margin-bottom: 2px;\">' + _renderIcon(_icon, 22, _accent) + '</div>';\n    } else if (_style === 'editorial') {\n        iconHtml = '<div data-role=\"pricing-icon\" data-variant=\"inline\" style=\"align-self: flex-start; color: ' + _accent + '; margin-bottom: 2px;\">' + _renderIcon(_icon, 20, _accent) + '</div>';\n    } else if (_style === 'brutalist') {\n        iconHtml = '<div data-role=\"pricing-icon\" data-variant=\"stamp-hard\" style=\"align-self: flex-start; color: #000; margin-bottom: 2px;\">' + _renderIcon(_icon, 24, '#000') + '</div>';\n    } else if (_style === 'banded') {\n        // Subtle mono icon in serif-card context — small, aligned with\n        // the serif heading below it.\n        iconHtml = '<div data-role=\"pricing-icon\" data-variant=\"banded-line\" style=\"align-self: flex-start; color: #44403c; margin-bottom: 4px;\">' + _renderIcon(_icon, 20, '#44403c') + '</div>';\n    } else if (_style === 'tier') {\n        // Accent-filled circle chip — feels like a \"rank medallion\" for\n        // each tier.\n        iconHtml = '<div data-role=\"pricing-icon\" data-variant=\"tier-chip\" style=\"align-self: flex-start; width: 40px; height: 40px; border-radius: 12px; background: ' + _accent + '18; color: ' + _accent + '; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 6px;\">' + _renderIcon(_icon, 22, _accent) + '</div>';\n    } else if (_style === 'neo') {\n        // Neumorphic icon cell — soft inset square with accent glyph.\n        iconHtml = '<div data-role=\"pricing-icon\" data-variant=\"neo-cell\" style=\"align-self: flex-start; width: 44px; height: 44px; border-radius: 14px; background: #eef1f6; color: ' + _accent + '; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 6px; box-shadow: inset 3px 3px 6px #c7cdd6, inset -3px -3px 6px #ffffff;\">' + _renderIcon(_icon, 22, _accent) + '</div>';\n    } else {\n        // minimal + highlighted + glass fallback\n        iconHtml = '<div data-role=\"pricing-icon\" data-variant=\"leading\" style=\"align-self: flex-start; color: ' + _accent + '; margin-bottom: 2px;\">' + _renderIcon(_icon, 22, _accent) + '</div>';\n    }\n}\n%><section data-pricing-card data-style=\"<%= _style %>\" data-highlighted=\"<%= _highlighted ? '1' : '0' %>\" style=\"<%- rootStyle %>\"><%- badgeHtml %><%- iconHtml %><% if (blocks && blocks.length) { blocks.forEach(function (block) { %><%- block %><% }); } %></section>\n","Divider":"<div style=\"<%- formatter.toStyleStringAll(['padding_top', 'padding_right', 'padding_bottom', 'padding_left']) %>\">\n    <div style=\"<%- formatter.toStyleStringAll(['background_color', 'height']) %><% if (formatter && formatter.getFormat && formatter.getFormat('divider_style')) { %><% var _h = formatter.getFormat('height') || 1; var _style = formatter.getFormat('divider_style'); var _bw = (_style === 'double') ? Math.max(4, _h*2) : _h; %> border-top: <%- _bw %>px <%- _style %> <%- formatter.getFormat('background_color') || '#000' %>; background-color: transparent; height: 0;<% } %><% if (formatter && formatter.getFormat && formatter.getFormat('divider_css')) { %><%- formatter.getFormat('divider_css') %><% } %>\"></div>\n</div>\n    ","Field":"<% if (fieldType === 'text') { %>\n    <div class=\"\">\n        <label class=\"form-label\" inline-edit=\"fieldLabel\"<% if (!showLabel) { %> style=\"display:none\" aria-hidden=\"true\"<% } %>><%- fieldLabel %></label><% if (required && requiredIndicator && showLabel) { %><span class=\"bjs-field-required-indicator\" style=\"display:inline-block;margin-left:7px;color:<%- requiredIndicatorColor %>;\"><%- requiredIndicator %></span><% } %>\n        <input type=\"text\" name=\"<%- fieldName %>\" value=\"<%- fieldValue %>\" placeholder=\"<%- fieldPlaceholder %>\" class=\"form-control\"<% if (required) { %> required<% } %><% if (errorMessage) { %> data-msg-required=\"<%- errorMessage %>\"<% } %> />\n    </div>\n\n<% } else if (fieldType === 'textarea') { %>\n    <div class=\"\">\n        <label class=\"form-label\" inline-edit=\"fieldLabel\"<% if (!showLabel) { %> style=\"display:none\" aria-hidden=\"true\"<% } %>><%- fieldLabel %></label><% if (required && requiredIndicator && showLabel) { %><span class=\"bjs-field-required-indicator\" style=\"display:inline-block;margin-left:7px;color:<%- requiredIndicatorColor %>;\"><%- requiredIndicator %></span><% } %>\n        <textarea name=\"<%- fieldName %>\" placeholder=\"<%- fieldPlaceholder %>\" class=\"form-control\"<% if (required) { %> required<% } %><% if (errorMessage) { %> data-msg-required=\"<%- errorMessage %>\"<% } %>><%- fieldValue %></textarea>\n    </div>\n\n<% } else if (fieldType === 'number') { %>\n    <div class=\"\">\n        <label class=\"form-label\" inline-edit=\"fieldLabel\"<% if (!showLabel) { %> style=\"display:none\" aria-hidden=\"true\"<% } %>><%- fieldLabel %></label><% if (required && requiredIndicator && showLabel) { %><span class=\"bjs-field-required-indicator\" style=\"display:inline-block;margin-left:7px;color:<%- requiredIndicatorColor %>;\"><%- requiredIndicator %></span><% } %>\n        <input type=\"number\" name=\"<%- fieldName %>\" value=\"<%- fieldValue %>\" placeholder=\"<%- fieldPlaceholder %>\" class=\"form-control\"<% if (required) { %> required<% } %><% if (errorMessage) { %> data-msg-required=\"<%- errorMessage %>\"<% } %> />\n    </div>\n\n<% } else if (fieldType === 'email') { %>\n    <div class=\"\">\n        <label class=\"form-label\" inline-edit=\"fieldLabel\"<% if (!showLabel) { %> style=\"display:none\" aria-hidden=\"true\"<% } %>><%- fieldLabel %></label><% if (required && requiredIndicator && showLabel) { %><span class=\"bjs-field-required-indicator\" style=\"display:inline-block;margin-left:7px;color:<%- requiredIndicatorColor %>;\"><%- requiredIndicator %></span><% } %>\n        <input type=\"email\" name=\"<%- fieldName %>\" value=\"<%- fieldValue %>\" placeholder=\"<%- fieldPlaceholder %>\" class=\"form-control\"<% if (required) { %> required<% } %><% if (errorMessage) { %> data-msg-required=\"<%- errorMessage %>\"<% } %><% if (emailErrorMessage) { %> data-msg-email=\"<%- emailErrorMessage %>\"<% } %> />\n    </div>\n\n<% } else if (fieldType === 'date') { %>\n    <div class=\"\">\n        <label class=\"form-label\" inline-edit=\"fieldLabel\"<% if (!showLabel) { %> style=\"display:none\" aria-hidden=\"true\"<% } %>><%- fieldLabel %></label><% if (required && requiredIndicator && showLabel) { %><span class=\"bjs-field-required-indicator\" style=\"display:inline-block;margin-left:7px;color:<%- requiredIndicatorColor %>;\"><%- requiredIndicator %></span><% } %>\n        <input type=\"date\" name=\"<%- fieldName %>\" value=\"<%- fieldValue %>\" class=\"form-control\"<% if (required) { %> required<% } %><% if (errorMessage) { %> data-msg-required=\"<%- errorMessage %>\"<% } %> />\n    </div>\n\n<% } else if (fieldType === 'datetime') { %>\n    <div class=\"\">\n        <label class=\"form-label\" inline-edit=\"fieldLabel\"<% if (!showLabel) { %> style=\"display:none\" aria-hidden=\"true\"<% } %>><%- fieldLabel %></label><% if (required && requiredIndicator && showLabel) { %><span class=\"bjs-field-required-indicator\" style=\"display:inline-block;margin-left:7px;color:<%- requiredIndicatorColor %>;\"><%- requiredIndicator %></span><% } %>\n        <input type=\"datetime-local\" name=\"<%- fieldName %>\" value=\"<%- fieldValue %>\" class=\"form-control\"<% if (required) { %> required<% } %><% if (errorMessage) { %> data-msg-required=\"<%- errorMessage %>\"<% } %> />\n    </div>\n\n<% } else if (fieldType === 'dropdown') { %>\n    <div class=\"\">\n        <label class=\"form-label\" inline-edit=\"fieldLabel\"<% if (!showLabel) { %> style=\"display:none\" aria-hidden=\"true\"<% } %>><%- fieldLabel %></label><% if (required && requiredIndicator && showLabel) { %><span class=\"bjs-field-required-indicator\" style=\"display:inline-block;margin-left:7px;color:<%- requiredIndicatorColor %>;\"><%- requiredIndicator %></span><% } %>\n        <select name=\"<%- fieldName %>\" class=\"form-select\"<% if (required) { %> required<% } %><% if (errorMessage) { %> data-msg-required=\"<%- errorMessage %>\"<% } %>>\n            <% if (required && !fieldValue) { %>\n                <option value=\"\"><%- fieldPlaceholder %></option>\n            <% } %>\n            <% fieldOptions.forEach(function(opt) { %>\n                <option value=\"<%- opt.value %>\" <%= fieldValue == opt.value ? \"selected\" : \"\" %>><%- opt.text %></option>\n            <% }); %>\n        </select>\n    </div>\n<% } else if (fieldType === 'multiselect') { %>\n    <div class=\"\">\n        <label class=\"form-label\" inline-edit=\"fieldLabel\"<% if (!showLabel) { %> style=\"display:none\" aria-hidden=\"true\"<% } %>><%- fieldLabel %></label><% if (required && requiredIndicator && showLabel) { %><span class=\"bjs-field-required-indicator\" style=\"display:inline-block;margin-left:7px;color:<%- requiredIndicatorColor %>;\"><%- requiredIndicator %></span><% } %>\n        <select name=\"<%- fieldName %>\" class=\"form-select\" multiple<% if (required) { %> required<% } %><% if (errorMessage) { %> data-msg-required=\"<%- errorMessage %>\"<% } %>>\n            <% if (required && !fieldValue) { %>\n                <option value=\"\"><%- fieldPlaceholder %></option>\n            <% } %>\n            <% fieldOptions.forEach(function(opt) { %>\n                <option value=\"<%- opt.value %>\" <%= fieldValue == opt.value ? \"selected\" : \"\" %>><%- opt.text %></option>\n            <% }); %>\n        </select>\n    </div>\n\n<% } else if (fieldType === 'checkbox') { %>\n    <div class=\"\">\n        <label class=\"form-label\" inline-edit=\"fieldLabel\"<% if (!showLabel) { %> style=\"display:none\" aria-hidden=\"true\"<% } %>><%- fieldLabel %></label><% if (required && requiredIndicator && showLabel) { %><span class=\"bjs-field-required-indicator\" style=\"display:inline-block;margin-left:7px;color:<%- requiredIndicatorColor %>;\"><%- requiredIndicator %></span><% } %>\n        <% fieldOptions.forEach(function(opt, idx) { %>\n            <div class=\"form-check\">\n                <input class=\"form-check-input\" type=\"checkbox\" name=\"<%- fieldName %>[]\" value=\"<%- opt.value %>\" id=\"<%- fieldName %>_<%- opt.value %>\" <%= (Array.isArray(fieldValue) && fieldValue.includes(opt.value)) ? \"checked\" : \"\" %><% if (required && idx === 0) { %> required<% } %><% if (errorMessage && idx === 0) { %> data-msg-required=\"<%- errorMessage %>\"<% } %> />\n                <label class=\"form-check-label\" for=\"<%- fieldName %>_<%- opt.value %>\"><%- opt.text %></label>\n            </div>\n        <% }); %>\n    </div>\n\n<% } else if (fieldType === 'radio') { %>\n    <div class=\"\">\n        <label class=\"form-label\" inline-edit=\"fieldLabel\"<% if (!showLabel) { %> style=\"display:none\" aria-hidden=\"true\"<% } %>><%- fieldLabel %></label><% if (required && requiredIndicator && showLabel) { %><span class=\"bjs-field-required-indicator\" style=\"display:inline-block;margin-left:7px;color:<%- requiredIndicatorColor %>;\"><%- requiredIndicator %></span><% } %>\n        <% fieldOptions.forEach(function(opt, idx) { %>\n            <div class=\"form-check\">\n                <input class=\"form-check-input\" type=\"radio\" name=\"<%- fieldName %>\" value=\"<%- opt.value %>\" id=\"<%- fieldName %>_<%- opt.value %>\" <%= (fieldValue == opt.value) ? \"checked\" : \"\" %><% if (required && idx === 0) { %> required<% } %><% if (errorMessage && idx === 0) { %> data-msg-required=\"<%- errorMessage %>\"<% } %> />\n                <label class=\"form-check-label\" for=\"<%- fieldName %>_<%- opt.value %>\"><%- opt.text %></label>\n            </div>\n        <% }); %>\n    </div>\n\n<% } else { %>\n    <div class=\"\">\n        <label class=\"form-label\" inline-edit=\"fieldLabel\"<% if (!showLabel) { %> style=\"display:none\" aria-hidden=\"true\"<% } %>><%- fieldLabel %></label><% if (required && requiredIndicator && showLabel) { %><span class=\"bjs-field-required-indicator\" style=\"display:inline-block;margin-left:7px;color:<%- requiredIndicatorColor %>;\"><%- requiredIndicator %></span><% } %>\n        <input type=\"text\" name=\"<%- fieldName %>\" value=\"<%- fieldValue %>\" placeholder=\"<%- fieldPlaceholder %>\" class=\"form-control\"<% if (required) { %> required<% } %><% if (errorMessage) { %> data-msg-required=\"<%- errorMessage %>\"<% } %> />\n    </div>\n<% } %>\n","SubmitButton":"\n<div style=\"text-align: <%- formatter.getFormat(\"align\", \"center\") %>;\">\n    <button type=\"submit\" style=\"<%- formatter.toStyleStringAll() %>box-sizing: border-box;\n        <% if (formatter && formatter.getFormat && formatter.getFormat('width')) { %>\n            display:block;\n            <% if (formatter.getFormat('align') === 'center') { %> margin-left: auto; margin-right: auto; <% } %>\n        <% } else { %>\n            display:inline-block;\n        <% } %>\n        text-decoration: none;\"\n        inline-edit=\"text\"><%- text %>\n    </button>\n</div>","TermsOfService":"<%\n    var msgAttr = errorMessage ? ' data-msg-required=\"' + String(errorMessage).replace(/\"/g, '&quot;') + '\"' : '';\n    var requiredAttr = required ? ' required' : '';\n    /* Structural flex styles are emitted INLINE because the builder canvas\n     * iframe doesn't load the host's visitor-side form-popup stylesheet.\n     * Class-based rules only apply on the visitor-side popup; inline styles\n     * guarantee the same layout in both surfaces. */\n    var rowStyle = 'display:flex;flex-wrap:wrap;align-items:flex-start;gap:8px;margin:0;';\n    /* `outline:none` suppresses Chrome/Edge's default contenteditable focus\n     * ring around the legal-text host — BuilderJS already paints its own\n     * selection chrome around the parent block, so the native ring just\n     * stacks awkwardly. Visitor-side the host has no `contenteditable`\n     * attribute (added at runtime by BuilderJS only inside the builder),\n     * so the rule is a no-op on the public form.  */\n    /* `min-width:0` + `overflow-wrap:anywhere` lets the text shrink below\n     * its natural longest-word width — without it, narrow viewports force\n     * a flex-wrap that knocks the checkbox onto its own row in inline\n     * layouts. With anywhere-wrap, min-content collapses to 1 character so\n     * the input + text always share the row regardless of viewport width.\n     * The validation error label still drops to a new row because IT has\n     * an explicit `flex-basis:100%` rule that forces the wrap. */\n    /* `flex: 1 1 0` (basis ZERO not auto) is critical: with `flex-basis:auto`\n     * the text item's intrinsic basis equals its full content width — on\n     * narrow viewports that exceeds container width and triggers a flex-\n     * wrap BEFORE the browser tries to shrink the item, dropping the\n     * checkbox onto its own row. With basis:0 the item starts at 0 width\n     * and grows into available space (= container - input - gap), so the\n     * sum of bases (input + 0) is always less than container — wrap never\n     * kicks in for the inline children. The validation error label still\n     * drops to a new row because IT has its own `flex-basis:100%` rule\n     * that explicitly forces wrap.\n     *\n     * `overflow-wrap:anywhere` + `word-break:break-word` belt-and-suspenders\n     * for ultra-narrow viewports where even a single long word wouldn't\n     * fit — text breaks mid-word rather than overflowing horizontally. */\n    var textStyle = 'flex:1 1 0;min-width:0;overflow-wrap:anywhere;word-break:break-word;outline:none;';\n    var inputStyle = 'flex:0 0 auto;margin-top:3px;';\n    var inputHtml = '<input type=\"checkbox\" name=\"' + inputName + '\" value=\"1\"' + requiredAttr + msgAttr + ' class=\"acm-terms__input\" style=\"' + inputStyle + '\" />';\n%>\n<% if (checkboxPosition === 'inline_left') { %>\n    <div class=\"acm-terms acm-terms--inline acm-terms--inline-left\" style=\"<%- formatter.toStyleStringAll() %>;<%- rowStyle %>\">\n        <%- inputHtml %>\n        <div class=\"acm-terms__text\" inline-edit=\"termsText\" style=\"<%- textStyle %>\"><%- termsText %></div>\n    </div>\n<% } else if (checkboxPosition === 'inline_right') { %>\n    <div class=\"acm-terms acm-terms--inline acm-terms--inline-right\" style=\"<%- formatter.toStyleStringAll() %>;<%- rowStyle %>\">\n        <div class=\"acm-terms__text\" inline-edit=\"termsText\" style=\"<%- textStyle %>\"><%- termsText %></div>\n        <%- inputHtml %>\n    </div>\n<% } else if (checkboxPosition === 'above') { %>\n    <div class=\"acm-terms acm-terms--stacked acm-terms--above\" style=\"<%- formatter.toStyleStringAll() %>\">\n        <label class=\"acm-terms__agree\" style=\"<%- rowStyle %>cursor:pointer;user-select:none;\">\n            <%- inputHtml %>\n            <span class=\"acm-terms__agree-label\" style=\"<%- textStyle %>line-height:1.45;\"><%- checkboxLabel %></span>\n        </label>\n        <div class=\"acm-terms__text\" inline-edit=\"termsText\" style=\"margin-top:6px;outline:none;\"><%- termsText %></div>\n    </div>\n<% } else { %>\n    <div class=\"acm-terms acm-terms--stacked acm-terms--below\" style=\"<%- formatter.toStyleStringAll() %>\">\n        <div class=\"acm-terms__text\" inline-edit=\"termsText\" style=\"margin-bottom:6px;outline:none;\"><%- termsText %></div>\n        <label class=\"acm-terms__agree\" style=\"<%- rowStyle %>cursor:pointer;user-select:none;\">\n            <%- inputHtml %>\n            <span class=\"acm-terms__agree-label\" style=\"<%- textStyle %>line-height:1.45;\"><%- checkboxLabel %></span>\n        </label>\n    </div>\n<% } %>\n","Checkout":"<style>\n    * {\n        box-sizing: border-box;\n        margin: 0;\n        padding: 0;\n    }\n\n    .checkout-form {\n        background: #fff;\n        border-radius: 8px;\n        overflow: hidden;\n        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);\n    }\n\n    .checkout-header {\n        padding: 16px 24px;\n        background: #f8f9fa;\n        border-bottom: 1px solid #e0e0e0;\n        display: flex;\n        align-items: center;\n        gap: 8px;\n        font-size: 14px;\n        color: #666;\n    }\n\n    .checkout-header-icon {\n        width: 20px;\n        height: 20px;\n    }\n\n    .section {\n        padding: 32px 24px;\n        border-bottom: 1px solid #e0e0e0;\n    }\n\n    .section:last-child {\n        border-bottom: none;\n    }\n\n    .section-title {\n        font-size: 16px;\n        font-weight: 600;\n        color: #1a1a1a;\n        margin-bottom: 20px;\n        letter-spacing: 0.3px;\n    }\n\n    .form-row {\n        display: grid;\n        grid-template-columns: 1fr 1fr;\n        gap: 16px;\n        margin-bottom: 16px;\n    }\n\n    .form-row.full-width {\n        grid-template-columns: 1fr;\n    }\n\n    .form-group {\n        position: relative;\n    }\n\n    .form-label {\n        display: block;\n        font-size: 13px;\n        font-weight: 500;\n        color: #555;\n        margin-bottom: 8px;\n    }\n\n    .form-label .optional {\n        color: #999;\n        font-weight: 400;\n    }\n\n    .form-input,\n    .form-select {\n        width: 100%;\n        padding: 14px 16px;\n        border: 1.5px solid #c5c5c5;\n        border-radius: 6px;\n        font-size: 14px;\n        font-weight: 500;\n        color: #333;\n        background: #fff;\n        transition: all 0.2s ease;\n        outline: none;\n        appearance: none;\n        -webkit-appearance: none;\n        -moz-appearance: none;\n    }\n\n    .form-select {\n        background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%23333' d='M1 1l5 5 5-5'/%3E%3C/svg%3E\");\n        background-repeat: no-repeat;\n        background-position: right 16px center;\n        padding-right: 40px;\n    }\n\n    .form-input:focus,\n    .form-select:focus {\n        border-color: #4a90e2;\n        box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1);\n    }\n\n    .form-input::placeholder {\n        color: #aaa;\n    }\n\n    .phone-input-wrapper {\n        display: flex;\n        gap: 8px;\n    }\n\n    .phone-flag {\n        width: 70px;\n        flex-shrink: 0;\n        padding: 12px 14px;\n        border: 1px solid #d0d0d0;\n        border-radius: 4px;\n        background: #fff;\n        display: flex;\n        align-items: center;\n        gap: 6px;\n        cursor: pointer;\n        transition: all 0.2s ease;\n    }\n\n    .phone-flag:hover {\n        border-color: #4a90e2;\n    }\n\n    .phone-flag img {\n        width: 20px;\n        height: 14px;\n        object-fit: cover;\n    }\n\n    .phone-flag-arrow {\n        font-size: 10px;\n        color: #666;\n        margin-left: auto;\n    }\n\n    .phone-input {\n        flex: 1;\n    }\n\n    .product-list {\n        margin-bottom: 24px;\n    }\n\n    .product-item {\n        display: flex;\n        align-items: center;\n        gap: 16px;\n        padding: 16px;\n        border: 1px solid #e0e0e0;\n        border-radius: 6px;\n        background: #fafafa;\n    }\n\n    .product-radio {\n        width: 20px;\n        height: 20px;\n        accent-color: #4a90e2;\n        cursor: pointer;\n    }\n\n    .product-name {\n        flex: 1;\n        font-size: 14px;\n        font-weight: 500;\n        color: #333;\n    }\n\n    .product-price {\n        font-size: 16px;\n        font-weight: 600;\n        color: #1a1a1a;\n    }\n\n    .delivery-info {\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        gap: 12px;\n        padding: 24px;\n        background: #f8f9fa;\n        border: 1px solid #e0e0e0;\n        border-radius: 6px;\n        margin-bottom: 24px;\n    }\n\n    .delivery-icon {\n        width: 32px;\n        height: 32px;\n        color: #666;\n    }\n\n    .delivery-text {\n        font-size: 13px;\n        color: #666;\n    }\n\n    .checkbox-group {\n        display: flex;\n        align-items: center;\n        gap: 10px;\n        cursor: pointer;\n    }\n\n    .checkbox-input {\n        width: 18px;\n        height: 18px;\n        accent-color: #4a90e2;\n        cursor: pointer;\n    }\n\n    .checkbox-label {\n        font-size: 14px;\n        font-weight: 500;\n        color: #333;\n        cursor: pointer;\n        user-select: none;\n    }\n\n    .payment-summary {\n        display: flex;\n        align-items: center;\n        gap: 16px;\n        padding: 20px;\n        background: #f8f9fa;\n        border: 1px solid #e0e0e0;\n        border-radius: 6px;\n        cursor: pointer;\n        transition: all 0.2s ease;\n    }\n\n    .payment-summary:hover {\n        background: #f0f1f2;\n    }\n\n    .payment-icon {\n        width: 24px;\n        height: 24px;\n        color: #333;\n    }\n\n    .payment-title {\n        flex: 1;\n        font-size: 15px;\n        font-weight: 600;\n        color: #1a1a1a;\n    }\n\n    .payment-cta {\n        font-size: 13px;\n        color: #4a90e2;\n        display: flex;\n        align-items: center;\n        gap: 8px;\n    }\n\n    .payment-arrow {\n        font-size: 10px;\n    }\n\n    .submit-button {\n        width: 100%;\n        margin-top: 20px;\n        padding: 14px 16px;\n        border: none;\n        border-radius: 6px;\n        background: linear-gradient(135deg, #4a90e2, #3a7acb);\n        color: #fff;\n        font-size: 15px;\n        font-weight: 700;\n        letter-spacing: 0.2px;\n        cursor: pointer;\n        box-shadow: 0 6px 18px rgba(58, 122, 203, 0.25);\n        transition: transform 0.15s ease, box-shadow 0.15s ease, filter 0.15s ease;\n    }\n\n    .submit-button:hover {\n        transform: translateY(-1px);\n        box-shadow: 0 8px 22px rgba(58, 122, 203, 0.3);\n    }\n\n    .submit-button:active {\n        transform: translateY(0);\n        box-shadow: 0 4px 12px rgba(58, 122, 203, 0.2);\n        filter: brightness(0.96);\n    }\n\n    .submit-button:focus-visible {\n        outline: 3px solid rgba(74, 144, 226, 0.35);\n        outline-offset: 2px;\n    }\n\n    .footer-notice {\n        text-align: center;\n        padding: 24px;\n        font-size: 12px;\n        color: #999;\n        background: #f8f9fa;\n    }\n</style>\n\n<form action=\"<%= action %>\" method=\"<%= method %>\">\n    <div class=\"checkout-container\" style=\"<%- formatter.toStyleStringAll() %>\">\n        <div class=\"checkout-form\">\n            <!-- Header -->\n            <div class=\"checkout-header\">\n                <svg class=\"checkout-header-icon\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                    <path d=\"M12 2L2 7v10c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V7l-10-5z\"/>\n                </svg>\n                <span><%- values.headerTitle %></span>\n            </div>\n\n            <!-- Contact Information Section -->\n            <section class=\"section\">\n                <h2 class=\"section-title\"><%- values.headlines.contact %></h2>\n                \n                <div class=\"form-row\">\n                    <div class=\"form-group\">\n                        <label class=\"form-label\"><%- values.contactLabels.firstName %></label>\n                        <input type=\"text\" name=\"first_name\" class=\"form-input\" placeholder=\"<%- values.contactLabels.firstName %>\">\n                    </div>\n                    <div class=\"form-group\">\n                        <label class=\"form-label\"><%- values.contactLabels.lastName %></label>\n                        <input type=\"text\" name=\"last_name\" class=\"form-input\" placeholder=\"<%- values.contactLabels.lastName %>\">\n                    </div>\n                </div>\n                \n                <div class=\"form-row full-width\">\n                    <div class=\"form-group\">\n                        <label class=\"form-label\"><%- values.contactLabels.email %></label>\n                        <input type=\"email\" name=\"email\" class=\"form-input\" placeholder=\"<%- values.contactLabels.email %>\">\n                    </div>\n                </div>\n                \n                <div class=\"form-row full-width\">\n                    <div class=\"form-group\">\n                        <label class=\"form-label\"><%- values.contactLabels.phone %></label>\n                        <div class=\"phone-input-wrapper\">\n                            <div class=\"phone-flag\">\n                                <img src=\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 20'%3E%3Crect fill='%23da251d' width='30' height='20'/%3E%3Cpolygon fill='%23ff0' points='15,4 11.47,14.85 20.71,8.15 9.29,8.15 18.53,14.85'/%3E%3C/svg%3E\" alt=\"VN\">\n                                <span class=\"phone-flag-arrow\">▼</span>\n                            </div>\n                            <input type=\"tel\" name=\"phone\" class=\"form-input phone-input\" placeholder=\"<%- values.contactLabels.phone %>\">\n                        </div>\n                    </div>\n                </div>\n            </section>\n\n            <!-- Billing Information Section -->\n            <section class=\"section\">\n                <h2 class=\"section-title\"><%- values.headlines.billing %></h2>\n                \n                <div class=\"form-row full-width\">\n                    <div class=\"form-group\">\n                        <label class=\"form-label\"><%- values.contactLabels.address %></label>\n                        <input type=\"text\" name=\"billing_address\" class=\"form-input\" placeholder=\"<%- values.contactLabels.address %>\">\n                    </div>\n                </div>\n\n                <div class=\"form-row full-width\">\n                    <div class=\"form-group\">\n                        <label class=\"form-label\"><%- values.contactLabels.apartment %> <span class=\"optional\"><%- values.contactLabels.apartmentOptional %></span></label>\n                        <input type=\"text\" name=\"billing_apartment\" class=\"form-input\" placeholder=\"<%- values.contactLabels.apartment %> <%- values.contactLabels.apartmentOptional %>\">\n                    </div>\n                </div>\n\n                <div class=\"form-row full-width\">\n                    <div class=\"form-group\">\n                        <label class=\"form-label\"><%- values.contactLabels.country %></label>\n                        <select name=\"billing_country_id\" class=\"form-select\">\n                            <option value=\"vietnam\"><%- values.defaults.country %></option>\n                            <option value=\"us\">United States</option>\n                            <option value=\"uk\">United Kingdom</option>\n                        </select>\n                    </div>\n                </div>\n\n                <div class=\"form-row full-width\">\n                    <div class=\"form-group\">\n                        <label class=\"form-label\"><%- values.contactLabels.state %></label>\n                        <select name=\"billing_state\" class=\"form-select\">\n                            <option value=\"an-giang\"><%- values.defaults.state %></option>\n                            <option value=\"hanoi\">Hanoi</option>\n                            <option value=\"hcm\">Ho Chi Minh</option>\n                        </select>\n                    </div>\n                </div>\n\n                <div class=\"form-row\">\n                    <div class=\"form-group\">\n                        <label class=\"form-label\"><%- values.contactLabels.city %></label>\n                        <input type=\"text\" name=\"billing_city\" class=\"form-input\" placeholder=\"<%- values.contactLabels.city %>\">\n                    </div>\n                    <div class=\"form-group\">\n                        <label class=\"form-label\"><%- values.contactLabels.postal %></label>\n                        <input type=\"text\" name=\"billing_postal\" class=\"form-input\" placeholder=\"<%- values.contactLabels.postal %>\">\n                    </div>\n                </div>\n            </section>\n\n            <!-- Payment Information Section -->\n            <section class=\"section\">\n                <h2 class=\"section-title\"><%- values.headlines.payment %></h2>\n                \n                <div class=\"payment-summary\">\n                    <svg class=\"payment-icon\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                        <rect x=\"1\" y=\"4\" width=\"22\" height=\"16\" rx=\"2\" ry=\"2\"/>\n                        <line x=\"1\" y1=\"10\" x2=\"23\" y2=\"10\"/>\n                    </svg>\n                    <span class=\"payment-title\"><%- values.paymentSummary.title %></span>\n                    <div class=\"payment-cta\">\n                        <span><%- values.paymentSummary.cta %></span>\n                        <svg class=\"payment-arrow\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                            <polyline points=\"6 9 12 15 18 9\"/>\n                        </svg>\n                    </div>\n                </div>\n\n                <button type=\"submit\" class=\"submit-button\"><%- values.buttonTexts.guestSubmit %></button>\n            </section>\n\n            <!-- Footer Notice -->\n            <div class=\"footer-notice\">\n                <%- values.headlines.privacy %>\n            </div>\n        </div>\n    </div>\n</form>","CheckoutControl":"<div class=\"bjs-checkout-control bjs-control-stack\">\n    <!-- UI Settings -->\n    <section class=\"bjs-checkout-section\">\n        <div class=\"bjs-checkout-section-title\">UI Settings</div>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Preview Label</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.ui.previewLabel %>\"\n                   data-path=\"ui.previewLabel\">\n        </label>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Mode Label</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.ui.modeLabel %>\"\n                   data-path=\"ui.modeLabel\">\n        </label>\n    </section>\n\n    <!-- Header -->\n    <section class=\"bjs-checkout-section\">\n        <div class=\"bjs-checkout-section-title\">Header</div>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Header Title</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.headerTitle %>\"\n                   data-path=\"headerTitle\">\n        </label>\n    </section>\n\n    <!-- Button Texts -->\n    <section class=\"bjs-checkout-section\">\n        <div class=\"bjs-checkout-section-title\">Button Texts</div>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Guest Submit Text</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.buttonTexts.guestSubmit %>\"\n                   data-path=\"buttonTexts.guestSubmit\">\n        </label>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Saved Button Text</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.buttonTexts.saved %>\"\n                   data-path=\"buttonTexts.saved\">\n        </label>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Upgrade/Downgrade Button Text</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.buttonTexts.upgrade %>\"\n                   data-path=\"buttonTexts.upgrade\">\n        </label>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Submitting Text</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.buttonTexts.submitting %>\"\n                   data-path=\"buttonTexts.submitting\">\n        </label>\n    </section>\n\n    <!-- Section Headlines -->\n    <section class=\"bjs-checkout-section\">\n        <div class=\"bjs-checkout-section-title\">Section Headlines</div>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Contact Information Headline</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.headlines.contact %>\"\n                   data-path=\"headlines.contact\">\n        </label>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Billing Information Headline</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.headlines.billing %>\"\n                   data-path=\"headlines.billing\">\n        </label>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Payment Information Headline</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.headlines.payment %>\"\n                   data-path=\"headlines.payment\">\n        </label>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Privacy Notice</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.headlines.privacy %>\"\n                   data-path=\"headlines.privacy\">\n        </label>\n    </section>\n\n    <!-- Contact Input Labels -->\n    <section class=\"bjs-checkout-section\">\n        <div class=\"bjs-checkout-section-title\">Contact Input Labels</div>\n\n        <div class=\"bjs-checkout-grid-2\">\n            <label class=\"bjs-checkout-field\">\n                <span class=\"bjs-checkout-field-label\">First Name</span>\n                <input type=\"text\" class=\"bjs-text-input\"\n                       value=\"<%= values.contactLabels.firstName %>\"\n                       data-path=\"contactLabels.firstName\">\n            </label>\n            <label class=\"bjs-checkout-field\">\n                <span class=\"bjs-checkout-field-label\">Last Name</span>\n                <input type=\"text\" class=\"bjs-text-input\"\n                       value=\"<%= values.contactLabels.lastName %>\"\n                       data-path=\"contactLabels.lastName\">\n            </label>\n        </div>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Email</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.contactLabels.email %>\"\n                   data-path=\"contactLabels.email\">\n        </label>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Phone Number</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.contactLabels.phone %>\"\n                   data-path=\"contactLabels.phone\">\n        </label>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Address</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.contactLabels.address %>\"\n                   data-path=\"contactLabels.address\">\n        </label>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Apartment/Building</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.contactLabels.apartment %>\"\n                   data-path=\"contactLabels.apartment\">\n        </label>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Optional Text</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.contactLabels.apartmentOptional %>\"\n                   data-path=\"contactLabels.apartmentOptional\">\n        </label>\n\n        <div class=\"bjs-checkout-grid-2\">\n            <label class=\"bjs-checkout-field\">\n                <span class=\"bjs-checkout-field-label\">Country</span>\n                <input type=\"text\" class=\"bjs-text-input\"\n                       value=\"<%= values.contactLabels.country %>\"\n                       data-path=\"contactLabels.country\">\n            </label>\n            <label class=\"bjs-checkout-field\">\n                <span class=\"bjs-checkout-field-label\">State</span>\n                <input type=\"text\" class=\"bjs-text-input\"\n                       value=\"<%= values.contactLabels.state %>\"\n                       data-path=\"contactLabels.state\">\n            </label>\n        </div>\n\n        <div class=\"bjs-checkout-grid-2\">\n            <label class=\"bjs-checkout-field\">\n                <span class=\"bjs-checkout-field-label\">City</span>\n                <input type=\"text\" class=\"bjs-text-input\"\n                       value=\"<%= values.contactLabels.city %>\"\n                       data-path=\"contactLabels.city\">\n            </label>\n            <label class=\"bjs-checkout-field\">\n                <span class=\"bjs-checkout-field-label\">Postal Code</span>\n                <input type=\"text\" class=\"bjs-text-input\"\n                       value=\"<%= values.contactLabels.postal %>\"\n                       data-path=\"contactLabels.postal\">\n            </label>\n        </div>\n    </section>\n\n    <!-- Default Values -->\n    <section class=\"bjs-checkout-section\">\n        <div class=\"bjs-checkout-section-title\">Default Values</div>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Default Country</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.defaults.country %>\"\n                   data-path=\"defaults.country\">\n        </label>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Default State</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.defaults.state %>\"\n                   data-path=\"defaults.state\">\n        </label>\n    </section>\n\n    <!-- Payment Summary -->\n    <section class=\"bjs-checkout-section\">\n        <div class=\"bjs-checkout-section-title\">Payment Summary</div>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Summary Title</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.paymentSummary.title %>\"\n                   data-path=\"paymentSummary.title\">\n        </label>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Summary CTA</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.paymentSummary.cta %>\"\n                   data-path=\"paymentSummary.cta\">\n        </label>\n    </section>\n\n    <!-- Colors -->\n    <section class=\"bjs-checkout-section\">\n        <div class=\"bjs-checkout-section-title\">Colors</div>\n\n        <div class=\"bjs-checkout-color-row\">\n            <span class=\"bjs-checkout-field-label\">Text Color</span>\n            <label class=\"bjs-checkout-color-chip\" style=\"--bjs-color-current: <%= values.colors && values.colors.text ? values.colors.text : '#ffffff' %>\">\n                <input type=\"color\" class=\"bjs-checkout-color-native\"\n                       value=\"<%= values.colors && values.colors.text ? values.colors.text : '#ffffff' %>\"\n                       data-path=\"colors.text\">\n                <span class=\"bjs-checkout-color-preview\"></span>\n            </label>\n        </div>\n\n        <div class=\"bjs-checkout-color-row\">\n            <span class=\"bjs-checkout-field-label\">Link Color</span>\n            <label class=\"bjs-checkout-color-chip\" style=\"--bjs-color-current: <%= values.colors && values.colors.link ? values.colors.link : '#ffffff' %>\">\n                <input type=\"color\" class=\"bjs-checkout-color-native\"\n                       value=\"<%= values.colors && values.colors.link ? values.colors.link : '#ffffff' %>\"\n                       data-path=\"colors.link\">\n                <span class=\"bjs-checkout-color-preview\"></span>\n            </label>\n        </div>\n    </section>\n</div>\n","CheckoutSimple":"<style>\n    * {\n        box-sizing: border-box;\n        margin: 0;\n        padding: 0;\n    }\n\n    .checkout-container {\n        max-width: 800px;\n        margin: 0 auto;\n        padding: 20px;\n        font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif;\n        color: #333;\n        background: #f5f5f5;\n    }\n\n    .checkout-form {\n        background: #fff;\n        border-radius: 8px;\n        overflow: hidden;\n        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);\n    }\n\n    .checkout-header {\n        padding: 16px 24px;\n        background: #f8f9fa;\n        border-bottom: 1px solid #e0e0e0;\n        display: flex;\n        align-items: center;\n        gap: 8px;\n        font-size: 14px;\n        color: #666;\n    }\n\n    .checkout-header-icon {\n        width: 20px;\n        height: 20px;\n    }\n\n    .section {\n        padding: 32px 24px;\n        border-bottom: 1px solid #e0e0e0;\n    }\n\n    .section:last-child {\n        border-bottom: none;\n    }\n\n    .section-title {\n        font-size: 16px;\n        font-weight: 600;\n        color: #1a1a1a;\n        margin-bottom: 20px;\n        letter-spacing: 0.3px;\n    }\n\n    .form-row {\n        display: grid;\n        grid-template-columns: 1fr 1fr;\n        gap: 16px;\n        margin-bottom: 16px;\n    }\n\n    .form-row.full-width {\n        grid-template-columns: 1fr;\n    }\n\n    .form-group {\n        position: relative;\n    }\n\n    .form-label {\n        display: block;\n        font-size: 13px;\n        font-weight: 500;\n        color: #555;\n        margin-bottom: 8px;\n    }\n\n    .form-input {\n        width: 100%;\n        padding: 14px 16px;\n        border: 1.5px solid #c5c5c5;\n        border-radius: 6px;\n        font-size: 14px;\n        font-weight: 500;\n        color: #333;\n        background: #fff;\n        transition: all 0.2s ease;\n        outline: none;\n        appearance: none;\n        -webkit-appearance: none;\n        -moz-appearance: none;\n    }\n\n    .form-input:focus {\n        border-color: #4a90e2;\n        box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1);\n    }\n\n    .form-input::placeholder {\n        color: #aaa;\n    }\n\n    .phone-input-wrapper {\n        display: flex;\n        gap: 8px;\n    }\n\n    .phone-flag {\n        width: 70px;\n        flex-shrink: 0;\n        padding: 12px 14px;\n        border: 1px solid #d0d0d0;\n        border-radius: 4px;\n        background: #fff;\n        display: flex;\n        align-items: center;\n        gap: 6px;\n        cursor: pointer;\n        transition: all 0.2s ease;\n    }\n\n    .phone-flag:hover {\n        border-color: #4a90e2;\n    }\n\n    .phone-flag img {\n        width: 20px;\n        height: 14px;\n        object-fit: cover;\n    }\n\n    .phone-flag-arrow {\n        font-size: 10px;\n        color: #666;\n        margin-left: auto;\n    }\n\n    .phone-input {\n        flex: 1;\n    }\n\n    .footer-notice {\n        text-align: center;\n        padding: 24px;\n        font-size: 12px;\n        color: #999;\n        background: #f8f9fa;\n    }\n\n    .submit-button {\n        width: 100%;\n        padding: 16px;\n        font-size: 16px;\n        font-weight: 600;\n        color: #fff;\n        background: #4a90e2;\n        border: none;\n        border-radius: 6px;\n        cursor: pointer;\n        transition: all 0.2s ease;\n        margin-top: 24px;\n    }\n\n    .submit-button:hover {\n        background: #357abd;\n    }\n\n    .submit-button:active {\n        transform: translateY(1px);\n    }\n</style>\n\n<div class=\"checkout-container\">\n    <div class=\"checkout-form\">\n        <!-- Header -->\n        <div class=\"checkout-header\">\n            <svg class=\"checkout-header-icon\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                <path d=\"M12 2L2 7v10c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V7l-10-5z\"/>\n            </svg>\n            <span><%- values.headerTitle %></span>\n        </div>\n\n        <!-- Contact Information Section -->\n        <section class=\"section\">\n            <h2 class=\"section-title\"><%- values.headlines.contact %></h2>\n            \n            <div class=\"form-row\">\n                <div class=\"form-group\">\n                    <label class=\"form-label\"><%- values.contactLabels.firstName %></label>\n                    <input type=\"text\" class=\"form-input\" placeholder=\"<%- values.contactLabels.firstName %>\">\n                </div>\n                <div class=\"form-group\">\n                    <label class=\"form-label\"><%- values.contactLabels.lastName %></label>\n                    <input type=\"text\" class=\"form-input\" placeholder=\"<%- values.contactLabels.lastName %>\">\n                </div>\n            </div>\n\n            <div class=\"form-row full-width\">\n                <div class=\"form-group\">\n                    <label class=\"form-label\"><%- values.contactLabels.email %></label>\n                    <input type=\"email\" class=\"form-input\" placeholder=\"<%- values.contactLabels.email %>\">\n                </div>\n            </div>\n\n            <div class=\"form-row full-width\">\n                <div class=\"form-group\">\n                    <label class=\"form-label\"><%- values.contactLabels.phone %></label>\n                    <div class=\"phone-input-wrapper\">\n                        <div class=\"phone-flag\">\n                            <img src=\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 20'%3E%3Crect fill='%23da251d' width='30' height='20'/%3E%3Cpolygon fill='%23ff0' points='15,4 11.47,14.85 20.71,8.15 9.29,8.15 18.53,14.85'/%3E%3C/svg%3E\" alt=\"VN\">\n                            <span class=\"phone-flag-arrow\">▼</span>\n                        </div>\n                        <input type=\"tel\" class=\"form-input phone-input\" placeholder=\"<%- values.contactLabels.phone %>\">\n                    </div>\n                </div>\n            </div>\n\n            <button class=\"submit-button\"><%- values.buttonTexts.submit %></button>\n        </section>\n\n        <!-- Footer Notice -->\n        <div class=\"footer-notice\">\n            <%- values.headlines.privacy %>\n        </div>\n    </div>\n</div>\n","CheckoutSimpleControl":"<div class=\"bjs-checkout-control bjs-control-stack\">\n    <!-- UI Settings -->\n    <section class=\"bjs-checkout-section\">\n        <div class=\"bjs-checkout-section-title\">UI Settings</div>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Preview Label</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.ui.previewLabel %>\"\n                   data-path=\"ui.previewLabel\">\n        </label>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Mode Label</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.ui.modeLabel %>\"\n                   data-path=\"ui.modeLabel\">\n        </label>\n    </section>\n\n    <!-- Header -->\n    <section class=\"bjs-checkout-section\">\n        <div class=\"bjs-checkout-section-title\">Header</div>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Header Title</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.headerTitle %>\"\n                   data-path=\"headerTitle\">\n        </label>\n    </section>\n\n    <!-- Button Texts -->\n    <section class=\"bjs-checkout-section\">\n        <div class=\"bjs-checkout-section-title\">Button Texts</div>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Submit Button Text</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.buttonTexts.submit %>\"\n                   data-path=\"buttonTexts.submit\">\n        </label>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Submitting Text</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.buttonTexts.submitting %>\"\n                   data-path=\"buttonTexts.submitting\">\n        </label>\n    </section>\n\n    <!-- Section Headlines -->\n    <section class=\"bjs-checkout-section\">\n        <div class=\"bjs-checkout-section-title\">Section Headlines</div>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Contact Information Headline</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.headlines.contact %>\"\n                   data-path=\"headlines.contact\">\n        </label>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Privacy Notice</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.headlines.privacy %>\"\n                   data-path=\"headlines.privacy\">\n        </label>\n    </section>\n\n    <!-- Contact Input Labels -->\n    <section class=\"bjs-checkout-section\">\n        <div class=\"bjs-checkout-section-title\">Contact Input Labels</div>\n\n        <div class=\"bjs-checkout-grid-2\">\n            <label class=\"bjs-checkout-field\">\n                <span class=\"bjs-checkout-field-label\">First Name</span>\n                <input type=\"text\" class=\"bjs-text-input\"\n                       value=\"<%= values.contactLabels.firstName %>\"\n                       data-path=\"contactLabels.firstName\">\n            </label>\n            <label class=\"bjs-checkout-field\">\n                <span class=\"bjs-checkout-field-label\">Last Name</span>\n                <input type=\"text\" class=\"bjs-text-input\"\n                       value=\"<%= values.contactLabels.lastName %>\"\n                       data-path=\"contactLabels.lastName\">\n            </label>\n        </div>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Email</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.contactLabels.email %>\"\n                   data-path=\"contactLabels.email\">\n        </label>\n\n        <label class=\"bjs-checkout-field\">\n            <span class=\"bjs-checkout-field-label\">Phone Number</span>\n            <input type=\"text\" class=\"bjs-text-input\"\n                   value=\"<%= values.contactLabels.phone %>\"\n                   data-path=\"contactLabels.phone\">\n        </label>\n    </section>\n</div>\n","FormContainerCell":"<div style=\"<%\n    var width = formatter.getFormat('width', 'auto');\n    var gap = (typeof cell_gap === 'number' && cell_gap > 0) ? cell_gap : 0;\n    var count = (typeof cell_count === 'number' && cell_count > 0) ? cell_count : 1;\n    var flexStyle = '';\n    if (width === 'auto' || width === null || width === '') {\n        flexStyle = '';\n    } else if (width === 'fill') {\n        flexStyle = 'flex: 1 1 0; min-width: 0;';\n    } else {\n        var str = String(width).trim();\n        if (/%$/.test(str)) {\n            var pct = parseFloat(str);\n            if (!isNaN(pct) && pct > 0) {\n                if (gap > 0 && count > 1) {\n                    var gapShare = (gap * (count - 1) / count);\n                    flexStyle = 'flex: 0 0 calc(' + pct + '% - ' + gapShare + 'px); min-width: 0;';\n                } else {\n                    flexStyle = 'flex: 0 0 ' + pct + '%; min-width: 0;';\n                }\n            } else {\n                flexStyle = 'flex: 1 1 0; min-width: 0;';\n            }\n        } else if (/(px|em|rem|vw|vh|ch|ex)$/i.test(str)) {\n            flexStyle = 'flex: 0 0 ' + str + '; min-width: 0;';\n        } else {\n            var numWidth = parseFloat(str);\n            if (!isNaN(numWidth) && numWidth > 0) {\n                flexStyle = 'flex: ' + numWidth + ' 1 0; min-width: 0;';\n            } else {\n                flexStyle = 'flex: 1 1 0; min-width: 0;';\n            }\n        }\n    }\n\n    var height = formatter.getFormat('height');\n    var heightStyle = '';\n    if (height !== null && height !== '' && height !== undefined) {\n        heightStyle = 'height: ' + height + (typeof height === 'number' ? 'px' : '') + ';';\n    }\n\n    var otherStyles = formatter.toStyleStringAll().replace(/width:\\s*[^;]+;?\\s*/gi, '').replace(/height:\\s*[^;]+;?\\s*/gi, '').trim();\n    var finalStyle = (flexStyle + ' ' + heightStyle + ' ' + otherStyles).trim();\n%><%- finalStyle %>\">\n    <% if (blocks && blocks.length) { blocks.forEach(function(block) { %>\n        <%- block %>\n    <% }); } %>\n</div>\n"};
window.THEME_CONFIG_DATA = {"name":"default","title":"Default Theme","description":"ACM Default Theme","pages":["Page","Form"],"templates":["Block","Grid","Cell","P","Link","H1","H2","H3","H4","H5","Heading","Image","Label","Button","Alert","RSS","Youtube","Menu","HTML","Video","SocialIcons","TextInput","Select","Radio","Checkbox","List","PricingCards","PricingCard","Divider","Field","SubmitButton","TermsOfService","Checkout","CheckoutControl","CheckoutSimple","CheckoutSimpleControl","FormContainerCell"]};
window.MEDIA_URL         = "../../../themes/default";
</script>

<script src="../../../dist/builder.js"></script>

<script>
    // Four arguments, four named ingredients above — IDENTICAL to snippet.php.
    const builder = new Builder({
        mainContainer:    '#MyCanvas',
        widgetsContainer: null,    // hide widgets palette in this minimal demo
        settingsContainer: null,   // hide settings panel — focus on the save flow
    });
    builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL);
    window.builder = builder; // expose for browser-console + legacy globals

    // Hand-rolled save — POST dir+sample+html+data to /backend/save.php.
    // Requires a PHP-aware server; see CAVEAT above for file:// behaviour.
    document.getElementById('b2SaveBtn').addEventListener('click', async () => {
        const status = document.getElementById('b2SaveStatus');
        status.textContent = 'Saving...';
        const fd = new FormData();
        fd.append('dir',    '_b2_demo');
        fd.append('sample', 'sample/HelloWorld');
        fd.append('html',   builder.getHtml());
        fd.append('data',   JSON.stringify(builder.getData()));
        try {
            const res = await fetch('/backend/save.php', { method: 'POST', body: fd });
            const j   = await res.json();
            status.textContent = j.status === 'success'
                ? 'Saved ✓ → themes/_b2_demo/sample/HelloWorld.{html,json}'
                : ('Error: ' + (j.message || 'unknown'));
        } catch (err) {
            status.textContent = 'Error: ' + err.message;
        }
    });
</script>
</body>
</html>

Notes

Why hand-rolled, not builder.save()? The engine's save() only POSTs html + data. The shipped /backend/save.php needs dir + sample too — they tell the handler where on disk to write. Hand-rolling the fetch lets you add any keys your backend needs (slug, user-id, CSRF token, …) without monkey-patching the engine.

What if I want a database, not files? Replace file_put_contents in your handler with a PDO call. The full MySQL recipe ships in Save to MySQL (W5) — same validator, same hand-rolled fetch shape (just slug instead of dir+sample), different storage layer.

Atomic-write safety. The shipped /backend/save.php writes to a tempfile, then rename()s into place. If your runtime crashes mid-write, the consumer never sees a half-written JSON file. Skip this only if your storage backend already provides atomic semantics (transaction-safe DBs, S3, etc.).

Production checklist: add CSRF tokens to the POST body, rate-limit per dir+sample pair (the validator's regex caps the namespace but doesn't cap volume), and route auth through your session layer before reaching the save handler.