/* ============================================================
   PPphp Designer — Component Registry
   One definition per component type:
     label, icon, category, container?, defaults(), render(node,ctx), schema(node)
   render() -> inner HTML for the canvas node (the wrapper is added by renderer)
   schema() -> [{ title, fields:[ {key,label,type,options?,target?} ] }]
     target: 'prop' (default) | 'node' | 'style' | 'visibility'
   ============================================================ */
(function () {
    'use strict';
    var PPD = window.PPD = window.PPD || {};

    function esc(v) { return String(v == null ? '' : v).replace(/[&<>"']/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[c]; }); }
    function title(v) { return String(v || '').replace(/_/g, ' ').replace(/\b\w/g, function (c) { return c.toUpperCase(); }); }
    // Universal icon markup: understands Bootstrap (`bi-x` / `bi bi-x`), FontAwesome
    // Pro (`fa-solid fa-x` / bare `fa-x`), Material (`material-symbols-outlined name`)
    // and any full class list. Bare legacy names fall back to Bootstrap.
    function icon(n) {
        var v = String(n == null ? '' : n).trim();
        if (!v) return '<i class="bi bi-square"></i>';
        if (v.indexOf('material-symbols') === 0) { var mp = v.split(/\s+/); return '<span class="material-symbols-outlined">' + esc(mp[mp.length - 1]) + '</span>'; }
        if (v.indexOf(' ') > -1) return '<i class="' + esc(v) + '"></i>';   // already a full class list
        if (v.indexOf('bi-') === 0) return '<i class="bi ' + esc(v) + '"></i>';
        if (v.indexOf('fa-') === 0) return '<i class="fa-solid ' + esc(v) + '"></i>';
        return '<i class="bi bi-' + esc(v) + '"></i>';                       // legacy bare bootstrap name
    }

    // marks a sub-element of a component as individually selectable (deep selection)
    function part(node, partId) { return 'data-ppd-id="' + esc(node.id) + '" data-ppd-part="' + esc(partId) + '" class="ppd-part"'; }
    function partCls(node, partId, cls) { return 'data-ppd-id="' + esc(node.id) + '" data-ppd-part="' + esc(partId) + '" class="ppd-part ' + esc(cls) + '"'; }
    // KPI (Summary) card responsive width: sanitise a per-device span (1–12; 0 = inherit the CSS default).
    function kpiSpan(v) { var n = parseInt(v, 10); return (n >= 1 && n <= 12) ? n : 0; }
    function kpiSpanVars(it) {
        var s = '';
        if (kpiSpan(it.spanD)) s += '--ksp:' + kpiSpan(it.spanD) + ';';
        if (kpiSpan(it.spanT)) s += '--kspt:' + kpiSpan(it.spanT) + ';';
        if (kpiSpan(it.spanM)) s += '--kspm:' + kpiSpan(it.spanM) + ';';
        return s;
    }
    // design mode keeps hidden sub-parts visible (dimmed) so they can be reselected & re-enabled
    function isDesign() { return PPD.renderMode !== 'preview'; }

    // shared metadata so render() and the inspector agree on defaults
    var META = {
        action: { view: { icon: 'bi-eye', label: 'View' }, edit: { icon: 'bi-pencil', label: 'Edit' }, 'inline-edit': { icon: 'bi-pencil-square', label: 'Inline edit' }, copy: { icon: 'bi-files', label: 'Copy' }, print: { icon: 'bi-printer', label: 'Print' }, delete: { icon: 'bi-trash', label: 'Delete', danger: true } },
        toolbar: { add: { icon: 'bi-plus-lg', label: 'Add new', variant: 'primary' }, delete: { icon: 'bi-trash', label: 'Delete', variant: 'danger' }, print: { icon: 'bi-printer', label: 'Print' }, export: { icon: 'bi-download', label: 'Export' }, import: { icon: 'bi-upload', label: 'Import' }, refresh: { icon: 'bi-arrow-clockwise', label: 'Refresh' } },
        formbtn: { save: { icon: 'bi-check-lg', label: 'Save', variant: 'primary' }, save_new: { icon: 'bi-plus-circle', label: 'Save & New' }, save_close: { icon: 'bi-check2-all', label: 'Save & Close' }, cancel: { icon: 'bi-x-lg', label: 'Cancel' }, home: { icon: 'bi-house', label: 'Back to Home' }, back: { icon: 'bi-arrow-left', label: 'Back' }, refresh: { icon: 'bi-arrow-clockwise', label: 'Refresh' }, edit: { icon: 'bi-pencil', label: 'Edit' }, print: { icon: 'bi-printer', label: 'Print' }, delete: { icon: 'bi-trash', label: 'Delete', variant: 'danger' }, import: { icon: 'bi-upload', label: 'Import', variant: 'primary' }, export: { icon: 'bi-download', label: 'Export', variant: 'primary' }, sample: { icon: 'bi-download', label: 'Download Sample' }, validate: { icon: 'bi-check2-circle', label: 'Validate & Preview' }, preview: { icon: 'bi-eye', label: 'Preview' }, csv: { icon: 'bi-filetype-csv', label: 'CSV' }, excel: { icon: 'bi-file-earmark-excel', label: 'Excel' }, pdf: { icon: 'bi-file-earmark-pdf', label: 'PDF' } }
    };
    // resolve a button/action's display config: base meta overlaid with per-key overrides stored on the node
    function resolve(group, key, overrides) { return Object.assign({}, (META[group] || {})[key] || { label: title(key) }, (overrides || {})[key] || {}); }

    // table-toolbar control metadata
    META.toolbarctrl = {
        add: { type: 'button', icon: 'bi-plus-lg', label: 'Add New', variant: 'primary' },
        delete_selected: { type: 'button', icon: 'bi-trash', label: 'Delete Selected', variant: 'danger', requiresSelection: true },
        update_selected: { type: 'button', icon: 'bi-pencil-square', label: 'Update Selected', variant: 'default', requiresSelection: true },
        inline_add: { type: 'button', icon: 'bi-plus-square-dotted', label: 'Inline Add', variant: 'default' },
        spreadsheet: { type: 'button', icon: 'bi-lock-fill', label: 'Spreadsheet — cells locked (click to edit)', variant: 'default', displayMode: 'icon' },
        search: { type: 'search', icon: 'bi-search', label: 'Search' },
        filter: { type: 'filter', icon: 'bi-funnel', label: 'Filter', variant: 'default' },
        custom: { type: 'placeholder', icon: 'bi-plus-lg', label: 'Custom button' },
        view_toggle: { type: 'viewtoggle', icon: 'bi-table', label: 'Table / Cards' },
        views: { type: 'views', icon: 'bi-bookmark-star', label: 'Views' },
        // The generated list toolbar renders this slot as the "Data" menu
        // (Export screen + Import screen + one-click downloads). The slot key
        // stays `export` so existing designs keep working; `transfer` is an alias.
        export: { type: 'export', icon: 'bi-arrow-down-up', label: 'Data', variant: 'default' },
        transfer: { type: 'export', icon: 'bi-arrow-down-up', label: 'Data', variant: 'default' },
        pdf: { type: 'button', icon: 'bi-file-earmark-pdf', label: 'PDF', variant: 'default', displayMode: 'icon' },
        record_info: { type: 'info', icon: 'bi-card-list', label: 'Records count', format: 'Displaying %from% – %to% of %total%' },
        page_size: { type: 'page_size', icon: 'bi-list-ol', label: 'Show' },
        print: { type: 'button', icon: 'bi-printer', label: 'Print', variant: 'default', displayMode: 'icon' },
        refresh: { type: 'button', icon: 'bi-arrow-clockwise', label: 'Refresh', variant: 'default', displayMode: 'icon' },
        density_toggle: { type: 'density', icon: 'bi-distribute-vertical', label: 'Density' },
        columns_toggle: { type: 'columns', icon: 'bi-layout-three-columns', label: 'Columns' }
    };
    // bulk actions live in the selection summary bar (shown when rows are checked)
    META.bulk = { delete_selected: { icon: 'bi-trash', label: 'Delete', variant: 'danger' }, update_selected: { icon: 'bi-pencil-square', label: 'Update' }, export_selected: { icon: 'bi-download', label: 'Export' } };
    // Format labels/icons mirror the generated app. The authoritative list comes
    // from the DataOps registry via the bootstrap (dataFormats), so registering a
    // new ExportFormat surfaces it here too; the literals below are only a
    // fallback for when the bootstrap is unavailable.
    var EXPORT_LABELS = { csv: 'CSV', xlsx: 'Excel Workbook', excel_html: 'Excel (legacy .xls)', xls: 'Excel (legacy .xls)', word: 'Word', docx: 'Word', json: 'JSON', xml: 'XML', pdf: 'PDF', print: 'Print' };
    var EXPORT_ICONS = {
        csv: ['bi-filetype-csv', '#0d9488'],
        xlsx: ['bi-file-earmark-spreadsheet-fill', '#217346'],
        excel_html: ['bi-file-earmark-excel-fill', '#217346'], xls: ['bi-file-earmark-excel-fill', '#217346'],
        word: ['bi-file-earmark-word-fill', '#2b579a'], docx: ['bi-file-earmark-word-fill', '#2b579a'],
        json: ['bi-filetype-json', '#f59e0b'], xml: ['bi-filetype-xml', '#7c3aed'],
        pdf: ['bi-file-earmark-pdf-fill', '#e2483d'], print: ['bi-printer', '#64748b']
    };
    /** Registry descriptors from the server, keyed by format id. */
    function dataFormats() {
        var boot = window.PpDesignerBootstrap || {};
        var out = {};
        (boot.dataFormats || []).forEach(function (f) { out[f.id] = f; });
        return out;
    }
    function fmtLabel(f) {
        var k = String(f).toLowerCase(), reg = dataFormats()[k];
        return (reg && reg.label) || EXPORT_LABELS[k] || title(f);
    }
    function fmtIcon(f) { var e = EXPORT_ICONS[String(f).toLowerCase()] || ['bi-file-earmark', '#64748b']; return '<i class="bi ' + e[0] + '" style="color:' + e[1] + '"></i> '; }

    // shared inspector groups
    // Locally bundled web fonts (assets/vendor/fonts) + safe system stacks —
    // a dropdown everywhere avoids typo-prone free-text font names.
    var FONT_CHOICES = ['', 'Inter', 'Barlow', 'DM Sans', 'Figtree', 'Fira Code', 'JetBrains Mono', 'Karla',
        'Lato', 'Lora', 'Manrope', 'Merriweather', 'Montserrat', 'Mulish', 'Noto Sans', 'Nunito', 'Open Sans',
        'Outfit', 'Playfair Display', 'Plus Jakarta Sans', 'Poppins', 'PT Sans', 'Raleway', 'Roboto',
        'Rubik', 'Source Sans 3', 'Space Grotesk', 'Ubuntu', 'Work Sans',
        // Sans-serif
        'Archivo', 'Cabin', 'Geist', 'Red Hat Display', 'Sora', 'Urbanist',
        // Serif
        'Bitter', 'Crimson Pro', 'EB Garamond', 'Roboto Slab', 'Source Serif 4',
        // Monospace
        'Geist Mono', 'IBM Plex Mono', 'Inconsolata', 'Red Hat Mono', 'Roboto Mono', 'Source Code Pro',
        // System stacks
        'system-ui', 'Georgia', 'monospace'];
    // Keep the blank ("inherit") first; show every font name A→Z. Future additions
    // sort themselves in, so insertion order above doesn't matter.
    FONT_CHOICES = [''].concat(FONT_CHOICES.filter(function (f) { return f; })
        .sort(function (a, b) { var x = a.toLowerCase(), y = b.toLowerCase(); return x < y ? -1 : (x > y ? 1 : 0); }));
    function styleGroup() {
        return { title: 'Style', fields: [
            { key: 'color', label: 'Text', type: 'color', target: 'style' },
            { key: 'background', label: 'Background', type: 'color', target: 'style' },
            { key: 'font', label: 'Font', type: 'font', target: 'style' },
            { key: 'fontSize', label: 'Font size', type: 'text', target: 'style', placeholder: '14px', hint: 'Any CSS size — e.g. 14px, 1.1rem, 90%.' },
            { key: 'fontWeight', label: 'Font weight', type: 'select', target: 'style', options: ['', 'normal', '500', '600', '700', '800'] },
            { key: 'align', label: 'Align', type: 'select', target: 'style', options: ['', 'left', 'center', 'right'] },
            { key: 'padding', label: 'Padding', type: 'text', target: 'style', placeholder: '8px 12px', hint: 'Inner spacing. One value, or “vertical horizontal”.' },
            { key: 'margin', label: 'Margin', type: 'text', target: 'style', placeholder: '0 0 12px 0', hint: 'Outer spacing: top right bottom left.' },
            { key: 'width', label: 'Width', type: 'text', target: 'style', placeholder: 'auto', hint: 'e.g. auto, 320px, 60%.' },
            { key: 'minHeight', label: 'Min height', type: 'text', target: 'style', placeholder: 'e.g. 120px' },
            { key: 'opacity', label: 'Opacity', type: 'text', target: 'style', placeholder: '0–1, e.g. .9', hint: '0 = transparent, 1 = solid.' },
            { key: 'border', label: 'Border', type: 'text', target: 'style', placeholder: '1px solid #e2e8f0', hint: 'CSS border shorthand: width style colour.' },
            { key: 'borderRadius', label: 'Border radius', type: 'text', target: 'style', placeholder: '12px', hint: 'Corner rounding, e.g. 8px or 50%.' },
            { key: 'shadow', label: 'Shadow', type: 'text', target: 'style', placeholder: '0 4px 16px rgba(0,0,0,.08)', hint: 'CSS box-shadow: x y blur spread colour.' },
            { key: 'reveal', label: 'Animate in', type: 'select', target: 'style', options: [['', 'None'], ['fade', 'Fade'], ['up', 'Fade up'], ['down', 'Fade down'], ['left', 'Slide from left'], ['right', 'Slide from right'], ['zoom', 'Zoom in']], hint: 'Reveal this component with a subtle animation the first time it scrolls into view. Respects “reduce motion”.' },
            { key: 'revealDelay', label: 'Animation delay', type: 'select', target: 'style', options: [['', 'None'], ['1', '100ms'], ['2', '200ms'], ['3', '300ms'], ['4', '400ms']], hint: 'Stagger several components by giving each a bigger delay.' },
            { key: 'class', label: 'CSS class', type: 'text', target: 'style', hint: 'Extra CSS class(es) added to this element.' },
            { key: 'customCss', label: 'Custom CSS — use & for this element', type: 'textarea', target: 'style', placeholder: 'box-shadow: 0 8px 24px rgba(0,0,0,.12);\n&:hover { transform: translateY(-2px); }', hint: 'Raw CSS scoped to this element; “&” = this element.' }
        ] };
    }
    function generalGroup(extra) {
        return { title: 'General', fields: [
            { key: 'label', label: 'Name', type: 'text', target: 'node' }
        ].concat(extra || []).concat([
            { key: 'visible', label: 'Visible', type: 'bool', target: 'visibility' }
        ]) };
    }

    // ── Per-part (sub-element) styling ──────────────────────────────────────
    // A composite widget (Stat Card, Welcome Hero…) exposes its inner pieces as
    // selectable `.ppd-part[data-ppd-part="el:<key>"]` elements. Their style is
    // stored in `props.partStyles[<key>]` and applied INLINE here on the canvas.
    // partStyleCss MUST stay a byte-for-byte mirror of
    // ComponentRenderer::partStyleCss so the canvas preview == the built app.
    function partStyleCss(bag) {
        bag = bag || {};
        var css = '';
        if (bag.color) css += 'color:' + bag.color + ';';
        if (bag.background) css += 'background:' + bag.background + ';';
        if (bag.font) css += 'font-family:' + bag.font + ';';
        if (bag.fontSize) css += 'font-size:' + bag.fontSize + ';';
        if (bag.fontWeight) css += 'font-weight:' + bag.fontWeight + ';';
        if (bag.align) css += 'text-align:' + bag.align + ';';
        if (bag.padding) css += 'padding:' + bag.padding + ';';
        return css;
    }
    // Build the attribute string for a sub-element from an explicit style bag:
    // keeps its base class, adds the .ppd-part hooks + inline style. Used directly
    // when a widget merges a legacy prop into the bag (e.g. kpi-stat value colour).
    function elAttrsFrom(key, baseCls, bag) {
        bag = bag || {};
        var extra = String(bag['class'] || '').replace(/[^a-zA-Z0-9_ -]/g, '').trim();
        var cls = (baseCls ? baseCls + ' ' : '') + 'ppd-part ppd-elpart' + (extra ? ' ' + extra : '');
        var css = partStyleCss(bag);
        return ' class="' + cls + '" data-ppd-part="el:' + esc(key) + '"' + (css ? ' style="' + esc(css) + '"' : '');
    }
    // `n` = node, `key` = part key — reads props.partStyles[key].
    function elAttrs(n, key, baseCls) { return elAttrsFrom(key, baseCls, ((n.props || {}).partStyles || {})[key] || {}); }

    var reg = {};
    function def(type, d) { d.type = type; reg[type] = d; }
    function get(type) { return reg[type] || reg._fallback; }
    function all() { return reg; }

    var ICONS = ['bi-eye', 'bi-pencil', 'bi-trash', 'bi-plus-lg', 'bi-check-lg', 'bi-x-lg', 'bi-printer', 'bi-download', 'bi-upload', 'bi-search', 'bi-gear', 'bi-funnel', 'bi-arrow-clockwise', 'bi-save', 'bi-files', 'bi-three-dots', 'bi-box-arrow-up-right', 'bi-filter'];

    /* ---------------- content ---------------- */
    def('heading', {
        label: 'Heading', icon: 'bi-type-h1', category: 'Content',
        defaults: function () { return { text: 'Heading', level: 'h2' }; },
        // Alignment/colour/font come from the shared Style panel (applied to the
        // wrapper and inherited) so canvas + generated app render identically.
        render: function (n) { var p = n.props; return '<div class="ppd-c-heading ' + esc(p.level || 'h2') + '">' + esc(p.text || 'Heading') + '</div>'; },
        schema: function () { return [ generalGroup([
            { key: 'text', label: 'Text', type: 'text' },
            { key: 'level', label: 'Level', type: 'select', options: ['h1', 'h2', 'h3'] }
        ]), styleGroup() ]; }
    });
    def('text', {
        label: 'Text', icon: 'bi-body-text', category: 'Content',
        defaults: function () { return { text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.' }; },
        render: function (n) { return '<div class="ppd-c-text">' + esc(n.props.text || '') + '</div>'; },
        schema: function () { return [ generalGroup([{ key: 'text', label: 'Text', type: 'textarea' }]), styleGroup() ]; }
    });
    def('button', {
        label: 'Button', icon: 'bi-square', category: 'Content',
        defaults: function () { return { label: 'Button', icon: 'bi-hand-index', action: '', displayMode: 'icon_label', variant: 'default' }; },
        render: function (n) { return btn(n.props, n.props.variant === 'primary' ? 'primary' : ''); },
        schema: function () { return [ generalGroup([
            { key: 'label', label: 'Label', type: 'text' },
            { key: 'action', label: 'Action', type: 'text',
              placeholder: '/generated/orders/list.php  ·  https://…  ·  js: alert("Saved")',
              hint: 'What happens when the button is clicked:\n• A page path or URL → navigates there (e.g. /generated/orders/list.php, https://example.com, #top, mailto:you@site.com).\n• "js:" followed by JavaScript → runs it on click (e.g. js: alert(\'Saved\')).\n• Leave blank for a non-interactive button.' },
            { key: 'icon', label: 'Icon', type: 'icon' },
            { key: 'displayMode', label: 'Display', type: 'select', options: ['icon', 'label', 'icon_label'] },
            { key: 'variant', label: 'Variant', type: 'select', options: ['default', 'primary', 'danger'] },
            { key: 'tooltip', label: 'Tooltip', type: 'text', hint: 'Hover text shown on the button in the generated app.' }
        ]), styleGroup() ]; }
    });

    function btn(p, cls) {
        var mode = p.displayMode || 'icon_label';
        var hasIcon = mode === 'icon' || mode === 'icon_label';
        var hasLabel = mode === 'label' || mode === 'icon_label';
        var variant = p.variant === 'primary' ? 'primary' : (p.variant === 'danger' ? 'danger' : '');
        return '<button type="button" class="ppd-btn ' + (cls || variant) + '" title="' + esc(p.tooltip || p.label || '') + '">' +
            (hasIcon ? icon(p.icon) : '') + (hasLabel ? '<span>' + esc(p.label || '') + '</span>' : '') + '</button>';
    }
    // a button that is itself a selectable sub-part of its parent component
    function partBtn(node, partId, m, extraCls) {
        var mode = m.displayMode || 'icon_label';
        var hasIcon = mode === 'icon' || mode === 'icon_label';
        var hasLabel = mode === 'label' || mode === 'icon_label';
        var variant = m.variant === 'primary' ? 'primary' : (m.variant === 'danger' || m.danger ? 'danger' : '');
        var tip = m.requiresSelection ? (m.label + ' — enabled when rows are selected') : (m.tooltip || m.label || '');
        return '<button type="button" ' + partCls(node, partId, 'ppd-btn ' + variant + ' ' + (extraCls || '')) + ' title="' + esc(tip) + '">' +
            (hasIcon ? icon(m.icon) : '') + (hasLabel ? '<span>' + esc(m.label || '') + '</span>' : '') + '</button>';
    }
    // classes for a sub-part's state (hidden / selection-dependent) in design mode
    function stateCls(m) {
        var c = '';
        if (m.visible === false) c += ' ppd-part-hidden';
        if (m.requiresSelection) c += ' ppd-needsel';
        return c;
    }

    /* ---------------- layout ---------------- */
    def('section', {
        label: 'Section', icon: 'bi-square', category: 'Layout', container: true,
        defaults: function () { return { heading: 'Section', variant: 'card', icon: '', foldable: false, closedInitially: false, headerHeight: 'md', spans: { desktop: 12, tablet: 12, mobile: 12 } }; },
        render: function (n, ctx) {
            var p = n.props;
            // Foldable is previewable on the canvas now: the chevron toggles an
            // ephemeral fold state (defaulting to `closedInitially`) so you can
            // see + test the built-app fold; expand to edit the children.
            var collapsed = !!(p.foldable && ctx.sectionFold && ctx.sectionFold(n));
            var fold = p.foldable
                ? '<i class="bi bi-chevron-' + (collapsed ? 'right' : 'down') + ' ppd-fold-ic" data-ppfold-canvas="' + esc(n.id) + '" title="Preview fold"></i>'
                : '';
            var hs = '';
            if (p.headerBg) hs += 'background:' + esc(p.headerBg) + ';';
            if (p.headerColor) hs += 'color:' + esc(p.headerColor) + ';';
            if (p.headerFont) hs += 'font-family:' + esc(p.headerFont) + ';';
            var sv = '';
            if (p.bodyBg) sv += '--sec-body-bg:' + esc(p.bodyBg) + ';';
            if (p.bodyPadding) sv += '--sec-body-pad:' + esc(p.bodyPadding) + ';';
            if (p.borderColor) sv += '--sec-border:' + esc(p.borderColor) + ';';
            if (p.radius) sv += '--sec-radius:' + esc(p.radius) + ';';
            var shadowCls = (p.variant !== 'plain' && p.shadow && p.shadow !== 'none') ? ' shadow-' + esc(p.shadow) : '';
            return '<div class="ppd-c-section ' + esc(p.variant || 'card') + (p.foldable ? ' is-foldable' : '') + (collapsed ? ' is-collapsed' : '') + shadowCls + '"' + (sv ? ' style="' + sv + '"' : '') + '>' +
                '<div class="h hh-' + esc(p.headerHeight || 'md') + '"' + (hs ? ' style="' + hs + '"' : '') + '>' + icon(p.icon || 'bi-collection') + '<span class="ppd-sec-title">' + esc(p.heading || n.label) + '</span>' + fold + '</div>' +
                ctx.renderChildren(n, 'b ppd-c-grid') + '</div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text', hint: 'Title shown in the section’s header bar.' },
            { key: 'icon', label: 'Heading icon', type: 'icon', hint: 'Small icon shown before the heading.' },
            { key: 'variant', label: 'Variant', type: 'select', options: ['card', 'plain'], hint: 'Card = bordered box with a shadow. Plain = the same frame without the shadow.' },
            { key: 'foldable', label: 'Foldable (show/hide body)', type: 'bool', hint: 'Add a chevron so users can collapse/expand the body. Click the chevron on the canvas to preview.' },
            { key: 'closedInitially', label: 'Closed initially', type: 'bool', when: 'foldable', hint: 'Start collapsed — the body is hidden until the user clicks to open it.' }
        ]), { title: 'Size & layout', fields: [
            { key: 'spans', label: 'Width', type: 'spans', hint: 'How many of the 12 columns this section spans, per device.' },
            { key: 'headerHeight', label: 'Header height', type: 'select', options: ['sm', 'md', 'lg'], hint: 'Vertical padding of the header bar.' },
            { key: 'headerBg', label: 'Header background', type: 'color', hint: 'Background colour of the header bar.' },
            { key: 'headerColor', label: 'Header text', type: 'color', hint: 'Text/icon colour in the header.' },
            { key: 'headerFont', label: 'Header font', type: 'font', hint: 'Font family for the heading text.' }
        ] }, { title: 'Frame', fields: [
            { key: 'shadow', label: 'Shadow (card only)', type: 'select', options: ['none', 'sm', 'md', 'lg'], hint: 'Drop-shadow depth. Applies to the Card variant only.' },
            { key: 'borderColor', label: 'Border color', type: 'color', hint: 'Colour of the section’s outer border. Blank = theme border.' },
            { key: 'radius', label: 'Corner radius', type: 'text', placeholder: 'e.g. 14px', hint: 'Roundness of the corners, e.g. 8px, 14px, 0.' }
        ] }, { title: 'Content area', fields: [
            { key: 'bodyBg', label: 'Background', type: 'color', hint: 'Background colour behind the section’s content.' },
            { key: 'bodyPadding', label: 'Padding', type: 'text', placeholder: 'e.g. 16px', hint: 'Inner spacing around the content, e.g. 16px.' }
        ] }, styleGroup() ]; }
    });
    def('row', {
        label: 'Layout Row', icon: 'bi-layout-three-columns', category: 'Layout', container: true,
        defaults: function () { return { columns: 2 }; },
        render: function (n, ctx) {
            return ctx.renderChildren(n, 'ppd-row cols-' + (parseInt(n.props.columns, 10) || 2));
        },
        schema: function () { return [ generalGroup([{ key: 'columns', label: 'Columns', type: 'select', options: ['1', '2', '3'] }]), styleGroup() ]; }
    });
    // Three-zone container: header / content / footer, each a drop target.
    // Mirrors ComponentRenderer's 'group-box'.
    // Real multi-zone container: header / content / footer, each a drop target.
    // The CONTENT zone is a 12-column grid (ppd-form12) so each child's own
    // Size & Layout span applies immediately on the canvas.
    def('group-box', {
        label: 'Group Container', icon: 'bi-bounding-box', category: 'Structure', container: true,
        defaults: function () {
            return { heading: 'Group', icon: 'bi-collection', headerMode: 'bar', showFooter: true,
                     footerText: '', borderStyle: 'solid', borderWidth: 1, radius: '14px',
                     padding: 'normal', gap: 'normal', accent: true };
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text' },
            { key: 'icon', label: 'Icon', type: 'icon' },
            { key: 'headerMode', label: 'Header', type: 'select', options: ['bar', 'legend', 'plain', 'none'] },
            { key: 'accent', label: 'Tinted header', type: 'bool' },
            { key: 'showFooter', label: 'Show footer', type: 'bool' },
            { key: 'footerText', label: 'Footer text', type: 'text' }
        ]), { title: 'Border', fields: [
            { key: 'borderStyle', label: 'Style', type: 'select', options: ['solid', 'dashed', 'dotted', 'double', 'none'] },
            { key: 'borderWidth', label: 'Width (px)', type: 'number' },
            { key: 'radius', label: 'Radius', type: 'text', placeholder: '14px' }
        ] }, { title: 'Spacing', fields: [
            { key: 'padding', label: 'Padding', type: 'select', options: ['none', 'tight', 'normal', 'roomy'] },
            { key: 'gap', label: 'Gap between items', type: 'select', options: ['tight', 'normal', 'roomy'] }
        ] }, styleGroup() ]; },
        render: function (nd, ctx) {
            var p = nd.props || {};
            var st = ['solid','dashed','dotted','double','none'].indexOf(p.borderStyle) !== -1 ? p.borderStyle : 'solid';
            var w = Math.max(0, Math.min(8, parseInt(p.borderWidth, 10) || 0));
            var r = /^\d{1,3}px$/.test(String(p.radius || '')) ? p.radius : '14px';
            var pad = ['none','tight','normal','roomy'].indexOf(p.padding) !== -1 ? p.padding : 'normal';
            var gap = ['tight','normal','roomy'].indexOf(p.gap) !== -1 ? p.gap : 'normal';
            var hm = ['bar','legend','plain','none'].indexOf(p.headerMode) !== -1 ? p.headerMode : 'bar';
            var css = 'border-style:' + st + ';border-width:' + w + 'px;border-radius:' + r + ';';
            var head = hm === 'none' ? '' :
                '<div class="ppd-gb-h">' +
                  (p.icon ? '<span class="ppd-gb-ic">' + icon(p.icon) + '</span>' : '') +
                  '<span class="ppd-gb-t">' + esc(p.heading || 'Group') + '</span>' +
                  ctx.renderZone(nd, 0, 'ppd-gb-zone', 'Header') +
                '</div>';
            var foot = (p.showFooter === false && !(p.footerText || '').trim()) ? '' :
                '<div class="ppd-gb-f">' +
                  ((p.footerText || '').trim() ? '<span>' + esc(p.footerText) + '</span>' : '') +
                  ctx.renderZone(nd, 2, 'ppd-gb-zone', 'Footer') +
                '</div>';
            return '<section class="ppd-gb hm-' + hm + ' pad-' + pad + (p.accent === false ? '' : ' has-accent') +
                '" style="' + esc(css) + '">' + head +
                '<div class="ppd-gb-b">' + ctx.renderZone(nd, 1, 'ppd-form12 gap-' + gap, 'Drop components here') + '</div>' +
                foot + '</section>';
        }
    });

    def('card', {
        label: 'Card', icon: 'bi-card-text', category: 'Layout', container: true,
        defaults: function () { return { heading: 'Card', showFooter: false, footerText: '', shadow: 'sm', accentSide: 'top' }; },
        render: function (n, ctx) {
            var p = n.props || {};
            var accent = p.accent, accentSide = p.accentSide === 'left' ? 'left' : 'top';
            var shadow = ['none', 'sm', 'md', 'lg'].indexOf(p.shadow) > -1 ? p.shadow : 'sm';
            var foldable = !!p.collapsible;
            var collapsed = !!(foldable && ctx.sectionFold && ctx.sectionFold(n));
            // card style
            var cs = '';
            if (p.bg && p.bg2) { var gd = { 'to-right': 'to right', 'to-bottom': 'to bottom', 'diagonal': '135deg' }[p.bgDir] || 'to bottom'; cs += 'background:linear-gradient(' + gd + ',' + esc(p.bg) + ',' + esc(p.bg2) + ');'; }
            else if (p.bg) cs += 'background:' + esc(p.bg) + ';';
            if (p.borderColor) cs += '--sec-border:' + esc(p.borderColor) + ';';
            if (p.radius) cs += '--sec-radius:' + esc(p.radius) + ';';
            if (p.textColor) cs += 'color:' + esc(p.textColor) + ';';
            if (p.font) cs += 'font-family:' + esc(p.font) + ';';
            if (accent) cs += '--card-accent:' + esc(accent) + ';';
            var cardCls = 'ppd-c-section card shadow-' + shadow + (p.hover ? ' is-hover' : '') + (accent ? ' is-accent-' + accentSide : '') + (foldable ? ' is-foldable' : '') + (collapsed ? ' is-collapsed' : '');
            // header
            var hs = '';
            if (p.headerBg) hs += 'background:' + esc(p.headerBg) + ';';
            else if (p.bg) hs += 'background:transparent;';
            if (p.headerColor) hs += 'color:' + esc(p.headerColor) + ';';
            if (p.headerFont) hs += 'font-family:' + esc(p.headerFont) + ';';
            if (['left', 'center', 'right'].indexOf(p.headerAlign) > -1) hs += 'justify-content:' + (p.headerAlign === 'center' ? 'center' : (p.headerAlign === 'right' ? 'flex-end' : 'flex-start')) + ';';
            var chev = foldable ? '<i class="bi bi-chevron-' + (collapsed ? 'right' : 'down') + ' ppd-fold-ic" data-ppfold-canvas="' + esc(n.id) + '" title="Preview fold"></i>' : '';
            var header = p.hideHeader ? '' : ('<div class="h"' + (hs ? ' style="' + hs + '"' : '') + '>' + icon(p.icon || 'bi-card-text') + esc(p.heading || n.label) + ctx.renderZone(n, 0, 'ppd-c-zone', 'Header') + chev + '</div>');
            // body
            var bs = '';
            if (p.padding) bs += 'padding:' + esc(p.padding) + ';';
            if (p.bodyBg) bs += 'background:' + esc(p.bodyBg) + ';';
            var body = '<div class="b"' + (bs ? ' style="' + bs + '"' : '') + '>' + ctx.renderZone(n, 1, 'ppd-form12 gap-normal', 'Drop components here') + '</div>';
            // footer
            var fs = '';
            if (p.footerBg) fs += 'background:' + esc(p.footerBg) + ';';
            if (p.footerColor) fs += 'color:' + esc(p.footerColor) + ';';
            var foot = (p.showFooter === false && !(p.footerText || '').trim()) ? '' :
                '<div class="ppd-c-foot"' + (fs ? ' style="' + fs + '"' : '') + '>' +
                  ((p.footerText || '').trim() ? '<span>' + esc(p.footerText) + '</span>' : '') +
                  ctx.renderZone(n, 2, 'ppd-c-zone', 'Footer') + '</div>';
            return '<div class="' + cardCls + '"' + (cs ? ' style="' + cs + '"' : '') + '>' + header + body + foot + '</div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text', hint: 'Card title in the header.' },
            { key: 'icon', label: 'Icon', type: 'icon', hint: 'Icon shown in the header.' },
            { key: 'hideHeader', label: 'Hide header', type: 'bool', hint: 'Remove the header row entirely (content-only card).' },
            { key: 'collapsible', label: 'Collapsible', type: 'bool', rerender: true, hint: 'Click the header to collapse / expand the body.' },
            { key: 'collapsedInitially', label: 'Start collapsed', type: 'bool', when: 'collapsible', hint: 'Show the card collapsed when the page loads.' },
            { key: 'showFooter', label: 'Show footer', type: 'bool', hint: 'Show a footer row (also appears if you drop components into the footer).' },
            { key: 'footerText', label: 'Footer text', type: 'text', hint: 'Text shown on the left of the footer.' }
        ]), { title: 'Card style', fields: [
            { key: 'bg', label: 'Background', type: 'color', hint: 'Card background colour.' },
            { key: 'bg2', label: 'Background 2 (gradient)', type: 'color', hint: 'Set a second colour to make the background a gradient.' },
            { key: 'bgDir', label: 'Gradient direction', type: 'select', options: ['to-bottom', 'to-right', 'diagonal'], hint: 'Direction of the background gradient (only used with Background 2).' },
            { key: 'accent', label: 'Accent bar colour', type: 'color', hint: 'A coloured bar along one edge of the card.' },
            { key: 'accentSide', label: 'Accent side', type: 'select', options: ['top', 'left'], hint: 'Which edge the accent bar sits on.' },
            { key: 'shadow', label: 'Shadow', type: 'select', options: ['none', 'sm', 'md', 'lg'], hint: 'Drop-shadow depth.' },
            { key: 'hover', label: 'Lift on hover', type: 'bool', hint: 'The card raises slightly with a bigger shadow when hovered.' },
            { key: 'radius', label: 'Corner radius', type: 'text', placeholder: '14px', hint: 'Rounded corners, e.g. 14px or 20px.' },
            { key: 'borderColor', label: 'Border colour', type: 'color', hint: 'Card border colour.' },
            { key: 'padding', label: 'Body padding', type: 'text', placeholder: '12px', hint: 'Inner spacing of the content area.' },
            { key: 'font', label: 'Card font', type: 'font', hint: 'Font family for the whole card.' },
            { key: 'textColor', label: 'Text colour', type: 'color', hint: 'Card text colour.' }
        ] }, { title: 'Header & footer', fields: [
            { key: 'headerBg', label: 'Header background', type: 'color', hint: 'Header bar colour (blank = follows the card background).' },
            { key: 'headerColor', label: 'Header text colour', type: 'color', hint: 'Colour of the header title.' },
            { key: 'headerAlign', label: 'Header align', type: 'select', options: ['left', 'center', 'right'], hint: 'Alignment of the header title.' },
            { key: 'headerFont', label: 'Header font', type: 'font', hint: 'Font family for the header.' },
            { key: 'bodyBg', label: 'Body background', type: 'color', hint: 'Content-area background (overrides the card background for the body).' },
            { key: 'footerBg', label: 'Footer background', type: 'color', hint: 'Footer bar colour.' },
            { key: 'footerColor', label: 'Footer text colour', type: 'color', hint: 'Colour of the footer text.' }
        ] }, styleGroup() ]; }
    });

    // A per-zone inline-style string (background / colour / font / size / align).
    // Bare numeric sizes get 'px'. Shared shape mirrored in ComponentRenderer.
    function mcZoneStyle(bg, col, font, size, align) {
        var s = '';
        if (bg) s += 'background:' + esc(bg) + ';';
        if (col) s += 'color:' + esc(col) + ';';
        if (font) s += 'font-family:' + esc(font) + ';';
        if (size) s += 'font-size:' + esc(/^\d+(\.\d+)?$/.test(String(size)) ? size + 'px' : size) + ';';
        if (align) s += 'text-align:' + esc(align) + ';';
        return s;
    }
    // Menu-card size → class, and border/shadow → inline style. Mirrored in
    // ComponentRenderer so canvas and build agree.
    var MC_SHADOWS = { none: 'none', soft: '0 4px 16px rgba(2,6,23,.07)', medium: '0 10px 26px rgba(2,6,23,.13)', strong: '0 18px 44px rgba(2,6,23,.20)' };
    function mcSize(p) { var s = (p && p.size) || 'md'; return ['sm', 'md', 'lg'].indexOf(s) !== -1 ? s : 'md'; }
    function mcCardStyle(p) {
        var s = '';
        var bs = p.cardBorderStyle || '';
        if (bs === 'none') s += 'border:none;';
        else if (bs) s += 'border:' + esc(p.cardBorderWidth || '1px') + ' ' + esc(bs) + ' ' + esc(p.cardBorderColor || '#e2e8f0') + ';';
        if (p.cardRadius) s += 'border-radius:' + esc(/^\d+(\.\d+)?$/.test(String(p.cardRadius)) ? p.cardRadius + 'px' : p.cardRadius) + ';';
        if (p.cardShadow && MC_SHADOWS[p.cardShadow]) s += 'box-shadow:' + MC_SHADOWS[p.cardShadow] + ';';
        if (p.minHeight) s += 'min-height:' + esc(/^\d+(\.\d+)?$/.test(String(p.minHeight)) ? p.minHeight + 'px' : p.minHeight) + ';';
        if (p.maxHeight) s += 'max-height:' + esc(/^\d+(\.\d+)?$/.test(String(p.maxHeight)) ? p.maxHeight + 'px' : p.maxHeight) + ';';
        return s;
    }
    // Menu / landing "module" card. Purpose-built (not a drop container): a
    // styleable header / description / footer, each independently toggled. With
    // BOTH description and footer OFF the whole card becomes a single click-
    // through tile linking to the table's list page (no inner buttons). Rendered
    // identically by ComponentRenderer::case 'menu-card' for the generated app.
    // NB: the CANVAS render is intentionally NON-navigating (plain <div>/<span>,
    // no href) — links must only work in the generated app, so accidental clicks
    // in the Designer never redirect. ComponentRenderer emits the real anchors.
    def('menu-card', {
        label: 'Menu Card', icon: 'bi-grid-1x2', category: 'Dashboard Widgets',
        _desc: 'A landing-page module card with a styleable header, description and footer. Turn description + footer off and the whole card becomes one click-through tile to the table.',
        defaults: function () { return {
            table: '', heading: 'Card', icon: 'bi-collection', link: '', size: 'md',
            showDescription: true, description: 'Browse, search and manage records.',
            showFooter: true, footerIcon: '', footerText: '',
            headerBg: '', headerColor: '', headerFont: '', headerSize: '', headerAlign: 'left',
            descBg: '', descColor: '', descFont: '', descSize: '', descAlign: 'left',
            footerBg: '', footerColor: '', footerFont: '', footerSize: '', footerAlign: 'left',
            cardBorderStyle: '', cardBorderWidth: '', cardBorderColor: '', cardRadius: '', cardShadow: '',
            minHeight: '', maxHeight: ''
        }; },
        render: function (n) {
            var p = n.props || {};
            var showDesc = p.showDescription !== false, showFoot = p.showFooter !== false;
            var clickable = !showDesc && !showFoot;
            var head = '<div class="pp-mc-head ppd-part" data-ppd-part="mcz:header" style="' + mcZoneStyle(p.headerBg, p.headerColor, p.headerFont, p.headerSize, p.headerAlign) + '">'
                + (p.icon ? '<span class="pp-mc-ic">' + icon(p.icon) + '</span>' : '')
                + '<span class="pp-mc-title">' + esc(p.heading || n.label || 'Card') + '</span></div>';
            var desc = showDesc ? '<div class="pp-mc-desc ppd-part" data-ppd-part="mcz:desc" style="' + mcZoneStyle(p.descBg, p.descColor, p.descFont, p.descSize, p.descAlign) + '">' + esc(p.description || '') + '</div>' : '';
            var foot = '';
            if (showFoot) {
                var lead = (p.footerIcon || (p.footerText || '').trim())
                    ? '<span class="pp-mc-flabel">' + (p.footerIcon ? icon(p.footerIcon) : '') + esc((p.footerText || '').trim()) + '</span>' : '';
                // Canvas buttons are inert spans (no navigation in the Designer).
                foot = '<div class="pp-mc-foot ppd-part" data-ppd-part="mcz:footer" style="' + mcZoneStyle(p.footerBg, p.footerColor, p.footerFont, p.footerSize, p.footerAlign) + '">'
                    + lead + '<span class="pp-mc-actions">'
                    + '<span class="pp-mc-btn">' + icon('bi-box-arrow-in-right') + '<span>Open</span></span>'
                    + ((p.table || '').trim() ? '<span class="pp-mc-btn">' + icon('bi-plus-lg') + '<span>Add new</span></span>' : '')
                    + '</span></div>';
            }
            var cls = 'pp-mc pp-mc-' + mcSize(p) + (clickable ? ' is-clickable' : '');
            var st = mcCardStyle(p);
            // Whole card is a plain <div> on the canvas — never a link.
            return '<div class="' + cls + '"' + (st ? ' style="' + st + '"' : '') + '>' + head + desc + foot + '</div>';
        },
        schema: function (n) {
            var p = (n && n.props) || {};
            var alignOpts = ['left', 'center', 'right'];
            var groups = [
                generalGroup([
                    { key: 'table', label: 'Table', type: 'dbtable' },
                    { key: 'heading', label: 'Heading', type: 'text' },
                    { key: 'icon', label: 'Header icon', type: 'icon' },
                    { key: 'size', label: 'Card size', type: 'select', options: ['sm', 'md', 'lg'] },
                    { key: 'link', label: 'Link override', type: 'text', placeholder: '/generated/<table>/list.php' },
                    { key: 'showDescription', label: 'Show description', type: 'bool', def: true, rerender: true },
                    { key: 'showFooter', label: 'Show footer', type: 'bool', def: true, rerender: true }
                ]),
                { title: 'Header', fields: [
                    { key: 'headerBg', label: 'Background', type: 'color' },
                    { key: 'headerColor', label: 'Text colour', type: 'color' },
                    { key: 'headerFont', label: 'Font', type: 'font'},
                    { key: 'headerSize', label: 'Font size', type: 'text', placeholder: 'e.g. 18px' },
                    { key: 'headerAlign', label: 'Align', type: 'select', options: alignOpts }
                ]},
                { title: 'Border & shadow', fields: [
                    { key: 'cardBorderStyle', label: 'Border', type: 'select', options: ['', 'none', 'solid', 'dashed', 'dotted'] },
                    { key: 'cardBorderWidth', label: 'Border width', type: 'text', placeholder: 'e.g. 2px' },
                    { key: 'cardBorderColor', label: 'Border colour', type: 'color' },
                    { key: 'cardRadius', label: 'Corner radius', type: 'text', placeholder: 'e.g. 14px' },
                    { key: 'cardShadow', label: 'Shadow', type: 'select', options: ['', 'none', 'soft', 'medium', 'strong'] }
                ]},
                { title: 'Dimensions', fields: [
                    { key: 'minHeight', label: 'Min height', type: 'text', placeholder: 'e.g. 160px' },
                    { key: 'maxHeight', label: 'Max height', type: 'text', placeholder: 'e.g. 240px' }
                ]}
            ];
            if (p.showDescription !== false) groups.push({ title: 'Description', fields: [
                { key: 'description', label: 'Text', type: 'textarea' },
                { key: 'descBg', label: 'Background', type: 'color' },
                { key: 'descColor', label: 'Text colour', type: 'color' },
                { key: 'descFont', label: 'Font', type: 'font'},
                { key: 'descSize', label: 'Font size', type: 'text', placeholder: 'e.g. 14px' },
                { key: 'descAlign', label: 'Align', type: 'select', options: alignOpts }
            ]});
            if (p.showFooter !== false) groups.push({ title: 'Footer', fields: [
                { key: 'footerIcon', label: 'Icon', type: 'icon' },
                { key: 'footerText', label: 'Label', type: 'text', placeholder: 'optional' },
                { key: 'footerBg', label: 'Background', type: 'color' },
                { key: 'footerColor', label: 'Text colour', type: 'color' },
                { key: 'footerFont', label: 'Font', type: 'font'},
                { key: 'footerSize', label: 'Font size', type: 'text', placeholder: 'e.g. 13px' },
                { key: 'footerAlign', label: 'Align', type: 'select', options: alignOpts }
            ]});
            groups.push(styleGroup());
            return groups;
        }
    });

    // Border/shadow + heading inline styles for the Menu Group. Mirrored in PHP.
    function mgBorderStyle(p) {
        var s = '';
        var bs = p.borderStyle || '';
        if (bs === 'none') s += 'border:none;';
        else if (bs) s += 'border:' + esc(p.borderWidth || '1px') + ' ' + esc(bs) + ' ' + esc(p.borderColor || '#e2e8f0') + ';';
        else { if (p.borderWidth) s += 'border-width:' + esc(p.borderWidth) + ';'; if (p.borderColor) s += 'border-color:' + esc(p.borderColor) + ';'; }
        if (p.radius) s += 'border-radius:' + esc(/^\d+(\.\d+)?$/.test(String(p.radius)) ? p.radius + 'px' : p.radius) + ';';
        if (p.shadow && MC_SHADOWS[p.shadow]) s += 'box-shadow:' + MC_SHADOWS[p.shadow] + ';';
        return s;
    }
    function mgHeadStyle(p) {
        var s = '';
        if (p.headingColor) s += 'color:' + esc(p.headingColor) + ';';
        if (p.headingFont) s += 'font-family:' + esc(p.headingFont) + ';';
        if (p.headingSize) s += 'font-size:' + esc(/^\d+(\.\d+)?$/.test(String(p.headingSize)) ? p.headingSize + 'px' : p.headingSize) + ';';
        return s;
    }
    var MG_VARIANTS = { legend: 'pp-mg-legend', 'legend-center': 'pp-mg-center', chip: 'pp-mg-chip', accent: 'pp-mg-accent', soft: 'pp-mg-soft' };
    // Menu Group — a bordered container (native <fieldset>/<legend>, so the
    // heading overlaps/interrupts the border) that holds Menu Cards. Styleable
    // border + shadow + heading, with several modern legend looks. Rendered
    // identically by ComponentRenderer::case 'menu-group'.
    def('menu-group', {
        label: 'Menu Group', icon: 'bi-collection', category: 'Dashboard Widgets', container: true,
        _desc: 'A bordered group whose heading overlaps the border (fieldset/legend). Drop Menu Cards inside. Styleable border, shadow and heading with several modern looks.',
        defaults: function () { return { heading: 'Group', icon: 'bi-collection', variant: 'legend',
            spacing: 'normal', equalHeight: false, bottomGap: 18,
            headingColor: '', headingFont: '', headingSize: '',
            borderStyle: '', borderWidth: '', borderColor: '', radius: '', shadow: '' }; },
        render: function (n, ctx) {
            var p = n.props || {};
            var vcls = MG_VARIANTS[p.variant] || 'pp-mg-legend';
            var sp = ['tight', 'normal', 'roomy'].indexOf(p.spacing) !== -1 ? p.spacing : 'normal';
            var eq = (p.equalHeight === true || p.equalHeight === 'yes') ? ' pp-mg-eq' : '';
            var st = mgBorderStyle(p), hs = mgHeadStyle(p);
            // Space between the last card row and the group's bottom border.
            var bg = parseInt(p.bottomGap, 10); if (isNaN(bg)) bg = 18;
            st += 'padding-bottom:' + Math.max(0, Math.min(120, bg)) + 'px;';
            return '<fieldset class="pp-mg ' + vcls + '"' + (st ? ' style="' + st + '"' : '') + '>'
                + '<legend class="pp-mg-legend"' + (hs ? ' style="' + hs + '"' : '') + '>'
                + (p.icon ? '<span class="pp-mg-ic">' + icon(p.icon) + '</span>' : '')
                + '<span class="pp-mg-t">' + esc(p.heading || n.label || 'Group') + '</span></legend>'
                // 12-column grid → each child honours its own Size & Layout span
                // (a card set to 3/12 = 4 per row). Menu-card spans are capped at a
                // minimum of 2 (max 6 per row) in renderNode / wrapComponent.
                + ctx.renderChildren(n, 'ppd-form12 gap-' + sp + ' pp-mg-body' + eq) + '</fieldset>';
        },
        schema: function () { return [
            generalGroup([
                { key: 'heading', label: 'Heading', type: 'text' },
                { key: 'icon', label: 'Heading icon', type: 'icon' },
                { key: 'variant', label: 'Style', type: 'select', options: ['legend', 'legend-center', 'chip', 'accent', 'soft'] }
            ]),
            { title: 'Layout', fields: [
                { key: 'spacing', label: 'Card spacing', type: 'select', options: ['tight', 'normal', 'roomy'] },
                { key: 'equalHeight', label: 'Equal-height cards', type: 'bool' },
                { key: 'bottomGap', label: 'Bottom gap', type: 'range', min: 0, max: 60, step: 2, suffix: 'px', def: 18 }
            ]},
            { title: 'Heading', fields: [
                { key: 'headingColor', label: 'Text colour', type: 'color' },
                { key: 'headingFont', label: 'Font', type: 'font'},
                { key: 'headingSize', label: 'Font size', type: 'text', placeholder: 'e.g. 16px' }
            ]},
            { title: 'Border & shadow', fields: [
                { key: 'borderStyle', label: 'Border', type: 'select', options: ['', 'none', 'solid', 'dashed', 'dotted'] },
                { key: 'borderWidth', label: 'Border width', type: 'text', placeholder: 'e.g. 2px' },
                { key: 'borderColor', label: 'Border colour', type: 'color' },
                { key: 'radius', label: 'Corner radius', type: 'text', placeholder: 'e.g. 16px' },
                { key: 'shadow', label: 'Shadow', type: 'select', options: ['', 'none', 'soft', 'medium', 'strong'] }
            ]},
            styleGroup()
        ]; }
    });

    /* ---------------- navigation / page chrome ---------------- */
    def('breadcrumb', {
        label: 'Breadcrumb', icon: 'bi-signpost-split', category: 'Navigation',
        defaults: function () { return { items: ['Home'], showHome: true, separator: '›' }; },
        render: function (n) {
            var items = n.props.items && n.props.items.length ? n.props.items : ['Home'];
            var sep = n.props.separator || '›';
            return '<div class="ppd-c-breadcrumb">' + items.map(function (it, i) {
                return (i ? '<span class="ppd-sep">' + esc(sep) + '</span>' : '') + '<span ' + partCls(n, 'item:' + i, 'ppd-chip') + '>' + esc(it) + '</span>';
            }).join('') + '</div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'items', label: 'Items (csv)', type: 'csv' },
            { key: 'separator', label: 'Separator', type: 'select', options: ['›', '/', '·', '→', '»'] }
        ]), styleGroup() ]; }
    });

    def('toolbar', {
        label: 'Toolbar', icon: 'bi-tools', category: 'Navigation',
        defaults: function () { return { buttons: ['add', 'delete', 'print', 'export', 'import'] }; },
        render: function (n) {
            var cfg = n.props.buttonConfig || {};
            var b = (n.props.buttons || []).map(function (k) {
                var m = resolve('toolbar', k, cfg);
                if (m.visible === false && !isDesign()) return '';
                return partBtn(n, 'button:' + k, m, stateCls(m).trim());
            }).join('');
            return '<div class="ppd-c-toolbar">' + b + '<span class="grow"></span></div>';
        },
        schema: function () { return [ generalGroup([{ key: 'buttons', label: 'Buttons', type: 'csv' }]), styleGroup() ]; }
    });

    def('search', {
        label: 'Search', icon: 'bi-search', category: 'Navigation',
        defaults: function () { return { placeholder: 'Search records...', table: '', searchFields: [], buttonText: 'Search', showButton: true, size: 'md', autofocus: false }; },
        render: function (n) {
            var p = n.props || {};
            var size = ['sm', 'md', 'lg'].indexOf(p.size) > -1 ? p.size : 'md';
            var box = '<div class="ppd-c-search">' + icon('bi-search') + '<input placeholder="' + esc(p.placeholder || 'Search...') + '" disabled></div>'
                + (p.showButton !== false ? '<button class="ppd-c-search-btn" disabled>' + esc(p.buttonText || 'Search') + '</button>' : '');
            var bound = (p.table || '').trim();
            var badge = bound ? ' <i class="bi bi-link-45deg ppd-dynbadge" title="Searches the ' + esc(title(bound)) + ' list"></i>' : ' <i class="bi bi-exclamation-triangle ppd-dynbadge" title="Pick a table to search" style="color:var(--pp-warning,#f59e0b)"></i>';
            return '<div class="ppd-c-search-form pp-search-' + size + '">' + box + badge + '</div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'table', label: 'Search which table', type: 'dbtable',
              hint: 'The table this box searches. Submitting jumps to that table’s list, filtered by the term.' },
            { key: 'searchFields', label: 'Fields to search (blank = all default)', type: 'dbcolumns', tableKey: 'table', full: true,
              hint: 'Pick the columns to match against. Leave empty to use the list’s default searchable fields.',
              note: 'The term is matched (LIKE) against <b>any</b> selected field.' },
            { key: 'placeholder', label: 'Placeholder', type: 'text', hint: 'Grey hint text inside the box (e.g. “Search customers…”).' },
            { key: 'showButton', label: 'Show Search button', type: 'bool', def: true, rerender: true, hint: 'Show a Search button next to the box (Enter also submits).' },
            { key: 'buttonText', label: 'Button text', type: 'text', when: 'showButton', hint: 'Label on the button (e.g. “Go”, “Find”).' },
            { key: 'size', label: 'Size', type: 'select', options: ['sm', 'md', 'lg'], hint: 'Overall width/height of the search box.' },
            { key: 'autofocus', label: 'Auto-focus on load', type: 'bool', hint: 'Put the cursor in the box automatically when the page opens.' },
            { key: 'newTab', label: 'Open results in a new tab', type: 'bool', hint: 'Open the filtered list in a new browser tab instead of navigating away.' }
        ]), styleGroup() ]; }
    });

    def('pagination', {
        label: 'Pagination', icon: 'bi-three-dots', category: 'Navigation',
        defaults: function () { return { source: 'dynamic', table: '', where: '', totalRecords: 0, pageSize: 10, align: 'left', size: 'md', showSummary: true }; },
        render: function (n) {
            var p = n.props || {};
            var dyn = (p.source || 'dynamic') === 'dynamic';
            var size = ['sm', 'md', 'lg'].indexOf(p.size) > -1 ? p.size : 'md';
            var align = ['left', 'center', 'right'].indexOf(p.align) > -1 ? p.align : 'left';
            var pages;
            if (!dyn) { var tot = parseInt(p.totalRecords, 10) || 0, ps = Math.max(1, parseInt(p.pageSize, 10) || 10); pages = Math.max(1, Math.ceil(tot / ps)); }
            else pages = 8;   // representative sample
            var shown = pages <= 4 ? Array.from({ length: pages }, function (_, i) { return i + 1; }) : [1, 2, 3, '…', pages];
            var btns = '<span class="ppd-pg">‹</span>' + shown.map(function (x) { return x === '…' ? '<span class="ppd-pg gap">…</span>' : '<span class="ppd-pg' + (x === 1 ? ' active' : '') + '">' + x + '</span>'; }).join('') + '<span class="ppd-pg">›</span>';
            var badge = dyn
                ? (p.table ? ' <i class="bi bi-link-45deg ppd-dynbadge" title="Live count from ' + esc(title(p.table)) + '"></i>' : ' <i class="bi bi-exclamation-triangle ppd-dynbadge" title="Pick a table to count" style="color:var(--pp-warning,#f59e0b)"></i>')
                : '';
            var summary = (p.showSummary !== false) ? '<span class="ppd-pg-summary">' + (dyn ? '204 records' : ((parseInt(p.totalRecords, 10) || 0) + ' records')) + '</span>' : '';
            return '<div class="ppd-c-pagination pp-pag-' + align + ' pp-pag-' + size + '">' + btns + summary + badge + '</div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'source', label: 'Count from', type: 'radiogroup', options: ['manual', 'dynamic'], rerender: true,
              hint: 'Dynamic = live record count from a table. Manual = you type the total.' },
            // ── Dynamic ──
            { key: 'table', label: 'Table', type: 'dbtable', when: { key: 'source', in: ['dynamic', ''] },
              hint: 'Counts this table’s records; the page links open that table’s list at ?page=N.' },
            { key: 'where', label: 'Filter (WHERE, optional)', type: 'text', full: true, when: { key: 'source', in: ['dynamic', ''] },
              placeholder: "status = 'Active' AND owner_id = :userid",
              hint: 'Only count rows matching this SQL condition (no “WHERE”). Supports SQL variables.',
              note: 'e.g. <code>status = \'Active\'</code> — supports <code>:userid</code>, <code>:today</code>, <code>:field.name</code>.' },
            // ── Manual ──
            { key: 'totalRecords', label: 'Total records', type: 'number', when: { key: 'source', in: ['manual'] },
              hint: 'The total number of records to paginate. Page links stay on THIS page (?page=N).' },
            // ── Common ──
            { key: 'pageSize', label: 'Records per page', type: 'number', hint: 'Rows per page — decides how many page buttons show.' },
            { key: 'align', label: 'Alignment', type: 'select', options: ['left', 'center', 'right'], hint: 'Horizontal position of the pager.' },
            { key: 'size', label: 'Size', type: 'select', options: ['sm', 'md', 'lg'], hint: 'Button size.' },
            { key: 'showSummary', label: 'Show record count', type: 'bool', def: true, hint: 'Append a small “N records” summary after the buttons.' }
        ]), styleGroup() ]; }
    });

    /* ---------------- table toolbar (3-zone) ---------------- */
    // Current table's shared list behaviour (pages.json settings.list) — drives
    // the canvas reflections of the freeze / views / group / kanban settings so
    // the Designer preview matches what the generated app builds (WYSIWYG).
    function curListSettings() {
        var t = ((PPD.model && PPD.model.activePage && PPD.model.activePage()) || {}).table ||
            (PPD.store && PPD.store.ui && PPD.store.ui('table')) || '';
        return (t && PPD.store && PPD.store.listSettings) ? PPD.store.listSettings(t) : {};
    }
    function renderTbCtrl(node, key, ctx) {
        var m = resolve('toolbarctrl', key, node.props.config);
        if (m.visible === false && !isDesign()) return '';   // truly hide only at runtime
        var pid = 'tb:' + key;
        var st = stateCls(m);   // ' ppd-part-hidden' / ' ppd-needsel'
        if (m.type === 'columns') {
            var cf = (ctx && ctx.fields && ctx.fields.length) ? ctx.fields : [{ name: 'id', label: 'ID' }, { name: 'name', label: 'Name' }];
            var items = cf.slice(0, 30).map(function (f) {
                return '<label class="ppd-tb-col"><input type="checkbox" checked disabled> ' + esc(f.label || title(f.name)) + '</label>';
            }).join('');
            return '<span ' + partCls(node, pid, 'ppd-tb-dd ppd-tb-cols' + st) + ' title="Show / hide columns">' + icon(m.icon) + '<span>' + esc(m.label) + '</span><i class="bi bi-caret-down-fill ppd-caret"></i>' +
                '<span class="ppd-tb-menu ppd-tb-menu-cols">' + items + '</span></span>';
        }
        if (m.type === 'search') {
            // Unified search field — mirrors the generated app: leading magnifier +
            // input + a trailing accent submit button INSIDE the field (no separate
            // Search button). props.searchStyle picks the design (glass/minimal/…).
            var _ss = String(node.props.searchStyle || 'glass').replace(/[^a-z]/g, '') || 'glass';
            var _sw = String(node.props.searchWidth || '').trim();
            var _swStyle = /^(auto|[0-9]+(\.[0-9]+)?(px|%|rem|em|ch|vw))$/i.test(_sw) ? ' style="max-width:' + _sw + ';flex-basis:' + _sw + '"' : '';
            return '<span ' + partCls(node, pid, 'ppd-tb-search ppd-tb-search-uni ppd-tb-search--' + _ss + st) + _swStyle + '>' +
                icon('bi-search') +
                '<input placeholder="' + esc(node.props.searchPlaceholder || 'Search records...') + '" disabled>' +
                '<span class="ppd-tb-search-go" title="Search"><i class="bi bi-arrow-right"></i></span>' +
                '</span>';
        }
        if (m.type === 'placeholder') {
            return '<button type="button" ' + partCls(node, pid, 'ppd-tb-placeholder' + st) + ' title="Custom button slot">' + icon(m.icon) + '<span>' + esc(m.label) + '</span></button>';
        }
        if (m.type === 'viewtoggle') {
            // Table / Cards segmented switch — mirrors the generated app's
            // .ppd-view-toggle. A third "Board" segment appears when a Kanban
            // field is configured (List settings → Board field); the unbound
            // Default List Template always shows it as a blueprint placeholder.
            var _isTpl = PPD.store && PPD.store.ui && PPD.store.ui('scope') === 'template';
            var board = (curListSettings().kanbanField || _isTpl) ? '<span class="ppd-vt-btn" title="Board (Kanban) view">' + icon('bi-kanban') + '</span>' : '';
            return '<span ' + partCls(node, pid, 'ppd-tb-viewtoggle' + st) + ' title="Table / Cards' + (board ? ' / Board' : '') + ' view">' +
                '<span class="ppd-vt-btn is-active">' + icon('bi-table') + '</span>' +
                '<span class="ppd-vt-btn">' + icon('bi-grid-3x3-gap') + '</span>' + board + '</span>';
        }
        if (m.type === 'export') {
            // The "Data" menu exactly as generated: the two transfer screens, then
            // a separator + the one-click downloads. Only formats the DataOps
            // registry actually provides are listed (PDF is its own page-type
            // button, never a download entry here).
            var known = dataFormats();
            var fmts = (node.props.exportFormats || ['xlsx', 'word', 'csv']).filter(function (f) {
                var k = String(f).toLowerCase();
                return Object.keys(known).length ? !!known[k] : ['xlsx', 'xls', 'excel_html', 'word', 'docx', 'csv', 'json', 'xml'].indexOf(k) > -1;
            });
            var items = '<span class="ppd-tb-menu-item">' + '<i class="bi bi-box-arrow-up-right" style="color:#2563eb"></i> ' + esc('Export records…') + '</span>' +
                '<span class="ppd-tb-menu-item">' + '<i class="bi bi-box-arrow-in-down-left" style="color:#16a34a"></i> ' + esc('Import records…') + '</span>';
            if (fmts.length) {
                items += '<span class="ppd-tb-menu-sep"></span><span class="ppd-tb-menu-cap">Quick download</span>' +
                    fmts.map(function (f) { return '<span class="ppd-tb-menu-item">' + fmtIcon(f) + esc(fmtLabel(f)) + '</span>'; }).join('');
            }
            return '<span ' + partCls(node, pid, 'ppd-tb-dd' + st) + ' title="Data — import / export">' + icon(m.icon) + '<span>' + esc(m.label) + '</span><i class="bi bi-caret-down-fill ppd-caret"></i>' +
                '<span class="ppd-tb-menu">' + items + '</span></span>';
        }
        if (m.type === 'views') {
            // Icon-only Saved-views button (tooltip only) — mirrors the generated app.
            return '<span ' + partCls(node, pid, 'ppd-tb-dd ppd-tb-icononly' + st) + ' title="Saved views">' + icon(m.icon) +
                '<span class="ppd-tb-menu"><span class="ppd-tb-menu-cap">Saved presets</span><span class="ppd-tb-menu-item"><i class="bi bi-plus-lg"></i> Save current view…</span></span></span>';
        }
        if (m.type === 'density') {
            var cur = node.props.density || 'compact';
            return '<span ' + partCls(node, pid, 'ppd-tb-dd' + st) + ' title="Row density">' + icon(m.icon) + '<span>' + esc(m.label) + '</span><i class="bi bi-caret-down-fill ppd-caret"></i>' +
                '<span class="ppd-tb-menu">' + ['compact', 'normal', 'comfortable'].map(function (o) { return '<span class="ppd-tb-menu-item' + (o === cur ? ' is-active' : '') + '">' + esc(title(o)) + '</span>'; }).join('') + '</span></span>';
        }
        if (m.type === 'info') {
            var fmt = node.props.recordInfo || m.format || 'Displaying %from% – %to% of %total%';
            var to = node.props.pageSizeDefault || 20;
            var txt = fmt.replace(/%from%/g, '1').replace(/%to%/g, String(to)).replace(/%total%/g, '204');
            // Full sentence on desktop; a compact "👁 1–20 / 204" on narrow (mirrors app).
            return '<span ' + partCls(node, pid, 'ppd-tb-info' + st) + '>' +
                '<i class="bi bi-eye ppd-ri-eye"></i>' +
                '<span class="ppd-ri-full">' + esc(txt) + '</span>' +
                '<span class="ppd-ri-short">1–' + to + ' / 204</span></span>';
        }
        if (m.type === 'page_size') {
            var def = node.props.pageSizeDefault || 20;
            var opts = node.props.pageSizes || [10, 20, 50, 'all'];
            return '<span ' + partCls(node, pid, 'ppd-tb-pagesize' + st) + ' title="Records per page"><span class="lbl">' + esc(m.label) + '</span><span class="box">' + esc(def) + ' <i class="bi bi-caret-down-fill"></i>' +
                '<span class="ppd-tb-menu">' + opts.map(function (o) { return '<span class="ppd-tb-menu-item">' + esc(o === 'all' ? 'All' : o) + '</span>'; }).join('') + '</span></span></span>';
        }
        // button / filter
        return partBtn(node, pid, m, st.trim());
    }
    function zone(node, name, ctx) {
        var arr = node.props[name] || [];
        return '<div class="ppd-tbzone ' + name + '">' + arr.map(function (k) { return renderTbCtrl(node, k, ctx); }).join('') + '</div>';
    }
    // second-tier "info" controls (counter + page size) vs first-tier utility (export/columns/print)
    function isInfoCtrl(node, key) {
        var t = resolve('toolbarctrl', key, node.props.config).type;
        return t === 'info' || t === 'page_size';
    }
    function ctrls(node, keys, ctx) { return (keys || []).map(function (k) { return renderTbCtrl(node, k, ctx); }).join(''); }
    def('list-toolbar', {
        label: 'Table Toolbar', icon: 'bi-ui-checks-grid', category: 'Data',
        defaults: function () {
            return {
                left: ['add', 'delete_selected', 'update_selected'],
                center: ['search', 'filter'],
                right: ['record_info', 'export', 'page_size', 'print', 'refresh', 'columns_toggle'],
                config: {},
                exportFormats: ['xlsx', 'word', 'csv'],
                pageSizes: [10, 20, 50, 'all'],
                pageSizeDefault: 20,
                searchPlaceholder: 'Search records...',
                searchStyle: 'glass',
                recordInfo: 'Displaying %from% – %to% of %total%'
            };
        },
        render: function (n, ctx) {
            // Three zones, mirroring the generated app. The Table/Cards/Board view
            // switch ALWAYS sits right after Filter (end of the centre zone); the
            // right zone is pure action icons. view_toggle is stripped from every
            // configured zone first so it never renders twice.
            var noVT = function (arr) { return (arr || []).map(String).filter(function (k) { return k !== 'view_toggle'; }); };
            var left = noVT(n.props.left), center = noVT(n.props.center), right = noVT(n.props.right);
            var vtOn = ((n.props.left || []).concat(n.props.center || [], n.props.right || []).map(String).indexOf('view_toggle') > -1);
            var lead = ['export', 'print', 'pdf'].filter(function (k) { return right.indexOf(k) > -1; });
            var rest = right.filter(function (k) { return lead.indexOf(k) === -1; });
            var grouped = rest.indexOf('record_info') > -1 && rest.indexOf('page_size') > -1;
            if (grouped) {
                rest = rest.filter(function (k) { return k !== 'page_size'; });
                rest.splice(rest.indexOf('record_info') + 1, 0, 'page_size');
            }
            var rightHtml = '';
            lead.concat(rest).forEach(function (k) {
                if (grouped && k === 'record_info') {
                    rightHtml += '<span class="ppd-tb-group">' + renderTbCtrl(n, 'record_info', ctx) + renderTbCtrl(n, 'page_size', ctx) + '</span>';
                } else if (grouped && k === 'page_size') {
                    // rendered inside the group
                } else {
                    rightHtml += renderTbCtrl(n, k, ctx);
                }
            });
            var centerHtml = ctrls(n, center, ctx);
            if (vtOn) centerHtml += '<span class="ppd-tb-viewsep"></span>' + renderTbCtrl(n, 'view_toggle', ctx);
            return '<div class="ppd-list-toolbar one-row">' +
                '<div class="ppd-tbzone left">' + ctrls(n, left, ctx) + '</div>' +
                '<div class="ppd-tbzone center">' + centerHtml + '</div>' +
                '<div class="ppd-tbzone right">' + rightHtml + '</div></div>';
        },
        schema: function () {
            return [ generalGroup([]), { title: 'Zones', fields: [
                { key: 'left', label: 'Left', type: 'csv' },
                { key: 'center', label: 'Center', type: 'csv' },
                { key: 'right', label: 'Right', type: 'csv' }
            ] }, { title: 'Options', fields: [
                { key: 'searchPlaceholder', label: 'Search text', type: 'text' },
                { key: 'exportFormats', label: 'Export', type: 'csv' },
                { key: 'pageSizes', label: 'Page sizes', type: 'csv' },
                { key: 'pageSizeDefault', label: 'Default size', type: 'text' }
            ] }, styleGroup() ];
        }
    });

    /* ---------------- print header / footer ---------------- */
    var SAMPLE_DATE = '2026-07-01';
    def('print-header', {
        label: 'Print Header', icon: 'bi-file-earmark-ruled', category: 'Layout',
        defaults: function () { return { org: 'Your Organization', title: 'Record Details', subtitle: '', showLogo: true, showDate: true, showRule: true }; },
        render: function (n) {
            var p = n.props;
            return '<div class="ppd-print-header">' +
                '<div class="ph-top">' +
                    (p.showLogo ? '<div class="ph-org">' + icon('bi-hexagon-fill') + '<span>' + esc(p.org || 'Organization') + '</span></div>' : '<span></span>') +
                    (p.showDate ? '<div class="ph-date">Date: ' + SAMPLE_DATE + '</div>' : '') +
                '</div>' +
                '<h1 class="ph-title">' + esc(p.title || 'Document') + '</h1>' +
                (p.subtitle ? '<div class="ph-sub">' + esc(p.subtitle) + '</div>' : '') +
                (p.showRule !== false ? '<hr class="ph-rule">' : '') +
            '</div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'org', label: 'Organization', type: 'text' },
            { key: 'title', label: 'Title', type: 'text' },
            { key: 'subtitle', label: 'Subtitle', type: 'text' },
            { key: 'showLogo', label: 'Show logo', type: 'bool' },
            { key: 'showDate', label: 'Show date', type: 'bool' },
            { key: 'showRule', label: 'Divider line', type: 'bool' }
        ]), styleGroup() ]; }
    });
    def('print-footer', {
        label: 'Print Footer', icon: 'bi-file-earmark-break', category: 'Layout',
        defaults: function () { return { left: '', center: 'Confidential', right: 'Page %page% of %pages%', showRule: true }; },
        render: function (n) {
            var p = n.props;
            function tok(s) { return esc(String(s || '').replace(/%page%/g, '1').replace(/%pages%/g, '1').replace(/%date%/g, SAMPLE_DATE)); }
            return '<div class="ppd-print-footer">' + (p.showRule !== false ? '<hr class="pf-rule">' : '') +
                '<div class="pf-row"><span class="pf-left">' + tok(p.left) + '</span><span class="pf-center">' + tok(p.center) + '</span><span class="pf-right">' + tok(p.right) + '</span></div></div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'left', label: 'Left', type: 'text' },
            { key: 'center', label: 'Center', type: 'text' },
            { key: 'right', label: 'Right (tokens: %page% %pages% %date%)', type: 'text' },
            { key: 'showRule', label: 'Divider line', type: 'bool' }
        ]), styleGroup() ]; }
    });

    /* ---------------- filter chips + selection bar ---------------- */
    def('filter-chips', {
        label: 'Filter Chips', icon: 'bi-funnel-fill', category: 'Navigation',
        defaults: function () { return { table: '', chips: [{ label: 'All', field: '', value: '' }], showClear: true, showCounts: false, align: 'left' }; },
        render: function (n) {
            var p = n.props || {};
            var align = ['left', 'center', 'right'].indexOf(p.align) > -1 ? p.align : 'left';
            var chips = (p.chips || []).filter(function (c) { return c && String(c.label || '').trim(); }).map(function (c) {
                var cnt = p.showCounts ? ' <span class="ppd-fchip-n">(12)</span>' : '';
                return '<span class="ppd-fchip">' + esc(c.label) + cnt + '</span>';
            }).join('');
            var badge = p.table ? '' : ' <i class="bi bi-exclamation-triangle ppd-dynbadge" title="Pick a table to filter" style="color:var(--pp-warning,#f59e0b)"></i>';
            return '<div class="ppd-filter-chips pp-fchips-' + align + '"><span class="ppd-fchips-label">' + icon('bi-funnel') + badge + '</span>' + chips +
                (p.showClear !== false ? '<span class="ppd-fchips-clear">Clear all</span>' : '') + '</div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'table', label: 'Filters which table', type: 'dbtable', hint: 'Clicking a chip opens this table’s list, filtered by the chip.' },
            { key: 'chips', label: 'Chips', type: 'list', itemLabel: 'Chip', addLabel: 'Add chip', tableKey: 'table', item: [
                { key: 'label', label: 'Label', type: 'text', hint: 'Text shown on the chip.' },
                { key: 'field', label: 'Field', type: 'column', tableKey: 'table', placeholder: '— any field / clear —', hint: 'Column to filter by. Blank = search all fields (or clear, if Value is also blank).' },
                { key: 'value', label: 'Value', type: 'text', hint: 'Field + Value → field contains Value · Value only → search · both blank → an “All / clear” chip.' }
            ] },
            { key: 'showCounts', label: 'Show live counts', type: 'bool', hint: 'Show “(N)” matching records on each chip (field chips + the All chip).' },
            { key: 'showClear', label: 'Show “Clear all”', type: 'bool', def: true, hint: 'Append a Clear-all link that removes every filter.' },
            { key: 'align', label: 'Alignment', type: 'select', options: ['left', 'center', 'right'], hint: 'Horizontal position of the chip row.' }
        ]), styleGroup() ]; }
    });
    def('where-tabs', {
        label: 'Filter Tabs', icon: 'bi-funnel', category: 'Navigation',
        defaults: function () { return { table: '', tabs: [{ label: 'Active', field: 'status', value: 'Active' }], showAll: true, showCounts: false, size: 'normal', fontFamily: '', fontSize: '', tabColor: '', tabBg: '', activeColor: '', activeBg: '', accent: '', badgeColor: '', badgeBg: '', badgeSize: '' }; },
        render: function (n) {
            var p = n.props || {};
            var pad = p.size === 'compact' ? '.25rem .65rem' : (p.size === 'large' ? '.6rem 1.2rem' : '.4rem .9rem');
            var baseFs = p.size === 'compact' ? '.82rem' : (p.size === 'large' ? '1rem' : '');
            var css = function (o) { return Object.keys(o).filter(function (k) { return o[k]; }).map(function (k) { return k + ':' + o[k]; }).join(';'); };
            var actStyle = css({ 'color': p.activeColor, 'background': p.activeBg, 'border-bottom-color': p.accent });
            var tabStyles = p.tabStyles || {};
            // Each tab is an individually selectable PART (tab:i). Its per-tab
            // overrides (props.tabStyles[i]) layer over the component-wide style.
            var tabEl = function (i, name, active) {
                var o = tabStyles[i] || {};
                var st = css({ 'font-family': o.fontFamily || p.fontFamily, 'font-size': o.fontSize || p.fontSize || baseFs,
                    'color': o.tabColor || p.tabColor, 'background': o.tabBg || p.tabBg, 'padding': o.padding || pad });
                var bs = css({ 'color': o.badgeColor || p.badgeColor, 'background': o.badgeBg || p.badgeBg,
                    'font-size': o.badgeSize || p.badgeSize, 'padding': (o.badgeBg || p.badgeBg) ? '0 .4rem' : '', 'border-radius': (o.badgeBg || p.badgeBg) ? '999px' : '' });
                var cnt = p.showCounts ? ' <span class="ppd-wt-badge" style="' + bs + '">(12)</span>' : '';
                return '<span ' + partCls(n, 'tab:' + i, 'ppd-wt-tab' + (active ? ' ppd-wt-active' : '')) + ' style="' + st + (active ? ';' + actStyle : '') + '">' + esc(name) + cnt + '</span>';
            };
            var items = (p.tabs || []).filter(function (t) { return t && String(t.label || '').trim(); });
            var tabs, hint = '';
            if (items.length) {
                tabs = items.map(function (t, i) { return tabEl(i, t.label, false); }).join('');
            } else {
                tabs = tabEl(0, 'Tab 1', false) + tabEl(1, 'Tab 2', false);
                hint = '<span class="ppd-wt-hint">' + icon('bi-info-circle') + ' add tabs below</span>';
            }
            var badge = (items.length && !p.table) ? ' <i class="bi bi-exclamation-triangle ppd-dynbadge" title="Pick a table" style="color:var(--pp-warning,#f59e0b)"></i>' : '';
            var all = (p.showAll !== false) ? tabEl('all', 'All data', true) : '';
            return '<div class="ppd-c-where-tabs pp-wt-' + (p.size || 'normal') + '" title="Click a tab to style it individually">' + tabs + all + badge + hint + '</div>';
        },
        schema: function () { return [
            { title: 'Data', fields: [
                { key: 'table', label: 'Filters which table', type: 'dbtable', hint: 'Clicking a tab opens this table’s list, filtered.' },
                { key: 'tabs', label: 'Tabs', type: 'list', itemLabel: 'Tab', addLabel: 'Add tab', tableKey: 'table', item: [
                    { key: 'label', label: 'Label', type: 'text', hint: 'Tab caption.' },
                    { key: 'field', label: 'Field', type: 'column', tableKey: 'table', placeholder: '— any field —', hint: 'Column to filter by (blank = search all fields).' },
                    { key: 'value', label: 'Value', type: 'text', hint: 'Field + Value → field contains Value · Value only → search.' }
                ] },
                { key: 'showAll', label: 'Show “All data” tab', type: 'bool', def: true, hint: 'Append a tab that clears the filter (shows everything).' },
                { key: 'showCounts', label: 'Show record counts', type: 'bool', hint: 'Show “(N)” matching records on each tab.' }
            ] },
            { title: 'Tabs', fields: [
                { key: 'size', label: 'Size', type: 'select', options: ['normal', 'compact', 'large'], hint: 'Tab padding / font size.' },
                { key: 'fontFamily', label: 'Font', type: 'font'},
                { key: 'fontSize', label: 'Font size', type: 'text', placeholder: '14px' },
                { key: 'tabColor', label: 'Text colour', type: 'color' },
                { key: 'tabBg', label: 'Background', type: 'color' }
            ] },
            { title: 'Active tab', fields: [
                { key: 'activeColor', label: 'Text colour', type: 'color' },
                { key: 'activeBg', label: 'Background', type: 'color' },
                { key: 'accent', label: 'Underline colour', type: 'color' }
            ] },
            { title: 'Count badge', fields: [
                { key: 'badgeColor', label: 'Text colour', type: 'color' },
                { key: 'badgeBg', label: 'Background', type: 'color' },
                { key: 'badgeSize', label: 'Font size', type: 'text', placeholder: '12px' }
            ] },
            generalGroup()
        ]; }
    });
    def('selection-bar', {
        label: 'Selection Bar', icon: 'bi-check2-square', category: 'Data',
        defaults: function () { return { actions: ['delete_selected', 'update_selected', 'export_selected'], countText: '%n% selected', showClear: true }; },
        render: function (n) {
            if (!isDesign()) return '';   // runtime: only visible once rows are selected
            var cfg = n.props.actionConfig || {};
            var acts = (n.props.actions || []).map(function (k) {
                var m = resolve('bulk', k, cfg);
                if (m.visible === false) return '';
                return partBtn(n, 'selact:' + k, m, stateCls(m).trim());
            }).join('');
            var count = String(n.props.countText || '%n% selected').replace(/%n%/g, '3');
            return '<div class="ppd-selbar">' + icon('bi-check2-circle') + '<strong class="ppd-selbar-count">' + esc(count) + '</strong>' + acts +
                (n.props.showClear !== false ? '<button type="button" class="ppd-selbar-clear">Clear</button>' : '') +
                '<span class="ppd-selbar-hint">shown when rows are selected</span></div>';
        },
        schema: function () { return [ generalGroup([{ key: 'countText', label: 'Count text', type: 'text' }, { key: 'actions', label: 'Actions', type: 'csv' }, { key: 'showClear', label: 'Clear button', type: 'bool' }]), styleGroup() ]; }
    });

    /* ---------------- data ---------------- */
    // Smart default controls from the REAL database column type (and a few
    // safe name heuristics) — so new columns never silently fall back to
    // PlainText / TextBox. Users refine them in the properties panel.
    function controlDefaultsFor(field) {
        var t = String(field.type || '').toLowerCase();
        var nm = String(field.name || '').toLowerCase();
        // Boolean by name even when stored as a plain int (is_active, has_paid, enabled…).
        var boolName = /^(is|has|can|should|allow|enable|disable)_[a-z0-9]/.test(nm)
            || ['active', 'enabled', 'disabled', 'published', 'verified', 'deleted', 'archived', 'featured',
                'visible', 'hidden', 'paid', 'completed', 'done', 'approved', 'locked', 'pinned', 'starred',
                'default', 'public', 'private'].indexOf(nm) > -1;
        if (nm.indexOf('email') > -1) return { viewControl: 'EmailHyperlink', editControl: 'TextBox', filterControl: 'TextFilter' };
        if (nm.indexOf('phone') > -1 || nm.indexOf('mobile') > -1) return { viewControl: 'PhoneNumber', editControl: 'TextBox', filterControl: 'TextFilter' };
        if (nm.indexOf('url') > -1 || nm.indexOf('website') > -1 || nm.indexOf('link') > -1) return { viewControl: 'Hyperlink', editControl: 'TextBox' };
        if (nm.indexOf('image') > -1 || nm.indexOf('photo') > -1 || nm.indexOf('avatar') > -1) return { viewControl: 'Image', editControl: 'FileImage' };
        if (nm === 'tags' || nm === 'labels' || nm === 'categories' || nm === 'skills') return { viewControl: 'Tags', editControl: 'TagInput', filterControl: 'TextFilter' };
        if (nm === 'rating' || nm === 'stars' || nm === 'score') return { viewControl: 'StarRating', editControl: 'StarRating', filterControl: 'NumberRange' };
        if (t.indexOf('tinyint(1)') === 0 || t === 'bool' || t === 'boolean' || boolName) return { viewControl: 'Boolean', editControl: 'Switch', filterControl: 'BooleanFilter' };
        if (t.indexOf('datetime') === 0 || t.indexOf('timestamp') === 0) return { viewControl: 'LongDate', editControl: 'DatePicker', filterControl: 'DateRange' };
        if (t.indexOf('date') === 0) return { viewControl: 'ShortDate', editControl: 'DatePicker', filterControl: 'DateRange' };
        if (t.indexOf('time') === 0) return { viewControl: '', editControl: 'TimePicker' };
        if (t.indexOf('decimal') === 0 || t.indexOf('float') === 0 || t.indexOf('double') === 0) return { viewControl: 'Number', editControl: 'TextBox', filterControl: 'NumberRange' };
        if (t.indexOf('int') === 0 || t.indexOf('bigint') === 0 || t.indexOf('smallint') === 0 || t.indexOf('tinyint') === 0 || t === 'number') return { viewControl: 'Number', editControl: 'TextBox', filterControl: 'NumberRange' };
        if (t.indexOf('text') > -1) return { viewControl: '', editControl: 'TextArea', filterControl: 'TextFilter' };
        return { viewControl: '', editControl: '', filterControl: '' };
    }
    function gridColumns(n, ctx) {
        if (n.props.columns && n.props.columns.length) return n.props.columns;
        var f = ctx.fields.slice(0, 8);
        return f.map(function (x) {
            var ctl = controlDefaultsFor(x);
            var col = { field: x.name, label: x.label || title(x.name), visible: true, sortable: true };
            if (ctl.viewControl) col.viewControl = ctl.viewControl;
            if (ctl.editControl) col.editControl = ctl.editControl;
            if (ctl.filterControl) col.filterControl = ctl.filterControl;
            return col;
        });
    }
    function sampleVal(field, row) {
        var f = String(field || '').toLowerCase();
        if (f === 'id') return row;
        if (f.indexOf('email') > -1) return 'user' + row + '@example.com';
        if (f.indexOf('date') > -1 || f.indexOf('time') > -1) return '2026-01-0' + row;
        if (f.indexOf('phone') > -1) return '98000000' + row;
        return title(f.replace(/_/g, ' ')) + ' ' + row;
    }
    // aggregate display labels
    var AGG = { none: '', sum: 'Σ Sum', avg: 'Avg', count: 'Count', min: 'Min', max: 'Max' };
    // effective per-cell style at a given row level: row default merged with per-column override
    function levelStyle(grid, col, level) {
        var rowDef = ((grid.props.rows || {})[level]) || {};
        var colLvl = col[level] || {};
        return {
            // backward-compatible with the backend's flat column shape (align/format/aggregate)
            align: colLvl.align || (level === 'header' ? col.align : '') || rowDef.align || 'left',
            color: colLvl.color || rowDef.color || '',
            background: colLvl.background || rowDef.background || '',
            format: colLvl.format || (level === 'cell' ? col.format : '') || '',
            aggregate: colLvl.aggregate || (level === 'footer' ? col.aggregate : '') || 'none',
            text: colLvl.text || '',
            // typography + box styling (per-cell override, then row default)
            font: colLvl.font || rowDef.font || '',
            fontSize: colLvl.fontSize || rowDef.fontSize || '',
            fontWeight: colLvl.fontWeight || rowDef.fontWeight || '',
            borderStyle: colLvl.borderStyle || rowDef.borderStyle || '',
            borderWidth: colLvl.borderWidth || rowDef.borderWidth || '',
            borderColor: colLvl.borderColor || rowDef.borderColor || '',
            padding: colLvl.padding || rowDef.padding || '',
            height: colLvl.height || rowDef.height || ''
        };
    }
    // normalize a stored icon value — preserve full class lists & foreign families, only prefix bare bootstrap names
    function iconCls(ic) {
        ic = String(ic || '').trim();
        if (!ic) return 'bi-square';
        if (ic.indexOf(' ') > -1 || ic.indexOf('bi-') === 0 || ic.indexOf('fa-') === 0 || ic.indexOf('material') === 0 || ic.indexOf('hero') === 0) return ic;
        return 'bi-' + ic;
    }
    // normalize grid actions whether they are strings or backend objects {type,text,icon}
    function actionKey(a) {
        var key = typeof a === 'string' ? a : (a && (a.type || a.action || a.id));
        if (!key || typeof key !== 'string' || key === '[object Object]') return null;
        return key;
    }
    function actionItems(p) {
        var cfg = p.actionConfig || {};
        var raw = Array.isArray(p.actions) ? p.actions : ['view', 'edit', 'delete'];
        var out = [], seen = {};
        raw.forEach(function (a) {
            var key = actionKey(a); if (!key || seen[key]) return; seen[key] = 1;
            var base = META.action[key] || {};
            var ov = cfg[key] || {};
            var src = (a && typeof a === 'object') ? a : {};
            out.push({
                key: key,
                icon: iconCls(ov.icon || src.icon || base.icon),
                label: ov.label || src.text || src.label || base.label || title(key),
                danger: ('danger' in ov) ? ov.danger : (src.danger != null ? src.danger : base.danger),
                displayMode: ov.displayMode || src.displayMode || 'icon',
                visible: ('visible' in ov) ? ov.visible : (src.visible != null ? src.visible : true)
            });
        });
        // all entries were missing/corrupt — fall back to a sensible default set
        if (!out.length) ['view', 'edit', 'delete'].forEach(function (key) {
            var base = META.action[key], ov = cfg[key] || {};
            out.push({ key: key, icon: iconCls(ov.icon || base.icon), label: ov.label || base.label, danger: ('danger' in ov) ? ov.danger : base.danger, displayMode: ov.displayMode || 'icon', visible: ov.visible !== false });
        });
        // keep hidden actions in design mode (dimmed & selectable) so they can be restored
        return isDesign() ? out : out.filter(function (x) { return x.visible !== false; });
    }
    var SORT_ICO = ' <i class="bi bi-arrow-down-up ppd-sort-ico"></i>';
    function styleStr(s) {
        var css = '';
        if (s.align && s.align !== 'left') css += 'text-align:' + s.align + ';';
        if (s.color) css += 'color:' + s.color + ';';
        if (s.background) css += 'background:' + s.background + ';';
        if (s.font) css += 'font-family:' + s.font + ';';
        if (s.fontSize) css += 'font-size:' + s.fontSize + ';';
        if (s.fontWeight) css += 'font-weight:' + s.fontWeight + ';';
        if (s.borderStyle || s.borderWidth || s.borderColor) {
            css += 'border:' + (s.borderWidth || '1px') + ' ' + (s.borderStyle || 'solid') + ' ' + (s.borderColor || 'currentColor') + ';';
        }
        if (s.padding) css += 'padding:' + s.padding + ';';
        if (s.height) css += 'height:' + s.height + ';';
        return css ? ' style="' + esc(css) + '"' : '';
    }

    // Master–Detail relations that show details links on this table's list
    // page (Tables module → Relation modal), in display order.
    function detailRels() {
        var kb = window.PpDesignerBootstrap || {};
        // active table lives in UI state (the page object has no .table)
        var t = ((PPD.model && PPD.model.activePage && PPD.model.activePage()) || {}).table ||
            (PPD.store && PPD.store.ui && PPD.store.ui('table')) || '';
        var rels = ((kb.relations) || []).filter(function (r) {
            return r.master_table === t && (r.master_pages || []).indexOf('list') > -1 && r.show_details_links !== false;
        }).sort(function (a, b) { return (a.display_order || 0) - (b.display_order || 0); });
        // Default List Page TEMPLATE (unbound, table='') is a BLUEPRINT: show a
        // demo "Details" master–detail column so the designer sees where child /
        // related records surface on tables that have a relationship, and can
        // style it (props.detailsCol) — exactly like the toolbar shows every
        // button. Real tables get their actual relations (or none). Mirrors the
        // _isTpl demo-affordances used elsewhere (e.g. the Board view-toggle).
        var isTpl = PPD.store && PPD.store.ui && PPD.store.ui('scope') === 'template';
        if (!rels.length && isTpl) {
            return [{
                id: '__tpl_demo_rel__', master_table: '', detail_table: 'details',
                master_field: 'id', detail_field: 'parent_id', master_pages: ['list'],
                show_details_links: true, details_link_mode: 'single',
                display_child_count: true, count_badge_color: '#64748b', display_order: 0,
                __demo: true
            }];
        }
        return rels;
    }
    // Effective per-relation link config: Designer overrides (props.detailsCol)
    // layered over the relation's own settings.
    function mdLinkCfg(dc, r, single) {
        var ov = ((dc.perRel || {})[r.id]) || {};
        var badgeMode = dc.badge || 'auto';
        return {
            label: ov.showLabel === false ? '' : (ov.label || (single ? 'Details' : title(r.detail_table))),
            icon: ov.icon || dc.icon || 'bi-chevron-right',
            showIcon: dc.showIcon !== false,
            badge: badgeMode === 'show' ? true : (badgeMode === 'hide' ? false : !!r.display_child_count),
            badgeBg: dc.badgeBg || r.count_badge_color || '#64748b',
            badgeColor: dc.badgeColor || '#fff',
            badgeSize: dc.badgeSize || 'md',
            badgeShape: dc.badgeShape || (dc.badgePill === false ? 'rounded' : 'pill')
        };
    }
    // Count-badge CSS for a size (sm/md/lg) + shape (pill/rounded/square/circle).
    // Mirrored by the build's renderTable so canvas and app match exactly.
    function mdBadgeCss(size, shape, bg, color) {
        var dims = { sm: [9, 15], md: [10, 19], lg: [12, 25] }[size] || [10, 19];
        var css = 'background:' + bg + ';color:' + color + ';font-size:' + dims[0] + 'px;';
        if (shape === 'circle') {
            css += 'width:' + dims[1] + 'px;height:' + dims[1] + 'px;min-width:0;padding:0;border-radius:50%;';
        } else {
            css += 'padding:' + (size === 'sm' ? '0 5px' : size === 'lg' ? '2px 8px' : '1px 6px') + ';'
                + 'border-radius:' + (shape === 'pill' ? '999px' : shape === 'square' ? '0' : '4px') + ';';
        }
        return css;
    }
    function mdLinkHtml(n, dc, r, single, sampleN) {
        var c = mdLinkCfg(dc, r, single);
        var style = (dc.linkColor ? 'color:' + dc.linkColor + ';' : '') + (dc.bold ? 'font-weight:700;' : '') + (dc.underline === false ? 'text-decoration:none;' : '');
        var badge = c.badge
            ? ' <span class="ppd-md-badge" style="' + esc(mdBadgeCss(c.badgeSize, c.badgeShape, c.badgeBg, c.badgeColor)) + '">' + sampleN + '</span>'
            : '';
        return '<span ' + partCls(n, 'mdlink:' + r.id, 'ppd-md-link') + (style ? ' style="' + esc(style) + '"' : '') + '>' +
            (c.showIcon ? icon(c.icon) + ' ' : '') + (c.label ? esc(c.label) : '') + badge + '</span>';
    }

    // The expandable child/detail PREVIEW panel shown below the list grid — a
    // WYSIWYG mirror of the runtime "Customer Details" section. Three selectable
    // zones (parts) all backed by props.detailsCol.preview:
    //   mdpreview  = the whole section (background / border / radius / padding)
    //   mdprevhead = the header bar   (title / icon / bg / text / hide)
    //   mdprevgrid = the child table  (striped / compact)
    // Effective child-preview columns for a relation: the designed list in
    // detailsCol.preview.cols[relId] (ordered, with label/visible/align/width)
    // or the child table's fields (minus the FK, max 6). Shared with the
    // inspector (per-column editor) and the renderer (drag reorder).
    function mdPrevCols(pv, r) {
        var stored = ((pv.cols || {})[r.id]) || [];
        if (stored.length) {
            return stored.map(function (c) { return Object.assign({ visible: true }, c); });
        }
        var kb = window.PpDesignerBootstrap || {};
        var names = ((kb.fieldsByTable || {})[r.detail_table] || []).map(function (f) { return f.name; })
            .filter(function (c) { return c !== r.detail_field; }).slice(0, 6);
        if (!names.length) names = ['id', 'type'];
        return names.map(function (c) { return { field: c, label: '', visible: true, align: '', width: '' }; });
    }
    // Effective columns for a detail-grid COMPONENT: the rich designed list
    // (props.columnsCfg), else the legacy CSV, else the child table's fields
    // (minus the FK, max 6). Shared with the inspector + drag reorder.
    // Effective columns for a view/edit RELATION SECTION: the designed list
    // in page.relationStyles[relId].cols or the child fields (minus FK, max 6).
    function relCols(st, r) {
        var stored = (st && st.cols) || [];
        if (stored.length) return stored.map(function (c) { return Object.assign({ visible: true }, c); });
        var kb = window.PpDesignerBootstrap || {};
        var names = ((kb.fieldsByTable || {})[r.detail_table] || []).map(function (f) { return f.name; })
            .filter(function (c) { return c !== r.detail_field; }).slice(0, 6);
        if (!names.length) names = ['id', 'type'];
        return names.map(function (c) { return { field: c, label: '', visible: true, align: '', width: '' }; });
    }
    function dgCols(p) {
        var cfg = p.columnsCfg || [];
        if (cfg.length) return cfg.map(function (c) { return Object.assign({ visible: true }, c); });
        var kb = window.PpDesignerBootstrap || {};
        var names = String(p.columns || '').split(',').map(function (s) { return s.trim(); }).filter(Boolean);
        if (!names.length) {
            names = ((kb.fieldsByTable || {})[p.table] || []).map(function (f) { return f.name; })
                .filter(function (c) { return c !== p.fkColumn; }).slice(0, 6);
        }
        if (!names.length) names = ['id', 'name'];
        return names.map(function (c) { return { field: c, label: '', visible: true, align: '', width: '' }; });
    }
    function mdPreviewPanel(n, dc, rels) {
        var pv = dc.preview || {};
        var r = rels[0];
        var kb = window.PpDesignerBootstrap || {};
        var cols = mdPrevCols(pv, r);
        var secCss = 'border:1px solid ' + (pv.border || 'var(--ppd-border,#e2e8f0)') + ';border-radius:' + (pv.radius || '12px') +
            ';background:' + (pv.bg || 'var(--ppd-surface,#fff)') + ';padding:' + (pv.pad || '14px') + ';';
        var headCss = 'display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px;' +
            (pv.headerBg ? 'background:' + pv.headerBg + ';border-radius:8px;padding:6px 10px;margin:-4px -4px 8px;' : '') +
            (pv.headerColor ? 'color:' + pv.headerColor + ';' : '');
        var cellPad = pv.compact ? '2px 8px' : '5px 8px';
        // per-column th (selectable part + drag handle) — hidden columns stay
        // visible but dimmed in design mode so they can be restored
        var head = cols.map(function (c) {
            var st = 'text-align:' + (c.align || 'left') + ';padding:' + cellPad + ';border-bottom:1px solid var(--ppd-border,#e2e8f0);color:#64748b;font-weight:600;' +
                (c.width ? 'width:' + c.width + ';' : '');
            return '<th ' + partCls(n, 'mdprevcol:' + r.id + ':' + c.field, 'ppd-mdprevcol' + (c.visible === false ? ' ppd-part-hidden' : '')) +
                ' style="' + esc(st) + '">' + esc(c.label || title(c.field)) + ' <i class="bi bi-grip-vertical ppd-mdcol-grip"></i></th>';
        }).join('');
        var body = '';
        for (var i = 0; i < 2; i++) {
            var stripe = (pv.striped && i % 2 === 1) ? 'background:var(--ppd-surface-soft,#f8fafc);' : '';
            body += '<tr style="' + stripe + '">' + cols.map(function (c) {
                return '<td style="text-align:' + (c.align || 'left') + ';padding:' + cellPad + ';border-bottom:1px solid var(--ppd-border,#eef2f7);' + (c.visible === false ? 'opacity:.35;' : '') + '">' +
                    '<span style="display:inline-block;width:' + (40 + (i * 17 % 40)) + 'px;height:8px;border-radius:4px;background:#e2e8f0;"></span></td>';
            }).join('') + '</tr>';
        }
        var t = pv.title || title(r.detail_table);
        var titleCss = 'font-size:13px;color:' + (pv.titleColor || 'inherit') + ';' + (pv.titleBold ? 'font-weight:800;' : '');
        var badgeCss = mdBadgeCss(pv.badgeSize || 'md', pv.badgeShape || 'pill', pv.badgeBg || '#e2e8f0', pv.badgeColor || '#475569');
        var childFn = (((kb.pagesConfig || {})[r.detail_table] || {}).functionality) || {};
        var btnCss = function (bg, col) { return (bg ? 'background:' + bg + ';border-color:' + bg + ';' : '') + (col ? 'color:' + col + ';' : ''); };
        var headInner = pv.hideHeader ? '' :
            ('<strong ' + partCls(n, 'mdprevtitle', 'ppd-mdprevpart') + ' style="' + esc(titleCss) + '">' + icon(pv.headerIcon || 'bi-diagram-3') + ' ' + esc(t) + '</strong>' +
             '<span ' + partCls(n, 'mdprevbadge', 'ppd-mdprevpart ppd-md-badge' + (pv.badgeHide ? ' ppd-part-hidden' : '')) + ' style="' + esc(badgeCss) + '">n</span>' +
             '<span style="flex:1"></span>' +
             (childFn.inlineAdd ? '<span ' + partCls(n, 'mdprevadd', 'ppd-mdprevpart ppd-btn' + (pv.addHide ? ' ppd-part-hidden' : '')) + ' style="font-size:10px;' + esc(btnCss(pv.addBg, pv.addColor)) + '">' + icon(pv.addIcon || 'bi-plus-lg') + ' ' + esc(pv.addLabel || 'Inline Add') + '</span>' : '') +
             (r.show_proceed_link ? '<span ' + partCls(n, 'mdprevgo', 'ppd-mdprevpart ppd-btn' + (pv.goHide ? ' ppd-part-hidden' : '')) + ' style="font-size:10px;' + esc(btnCss(pv.goBg, pv.goColor)) + '">' + icon(pv.goIcon || 'bi-box-arrow-up-right') + ' ' + esc(pv.goLabel || 'Proceed') + '</span>' : ''));
        return '<div class="ppd-mdprevwrap">' +
            '<div class="ppd-mdprev-cap">' + icon('bi-arrows-expand') + ' Child / detail section — expands under a row at runtime · click a zone or component to style · drag columns to reorder</div>' +
            '<div ' + partCls(n, 'mdpreview', 'ppd-mdpreview') + ' style="' + esc(secCss) + '">' +
                '<div ' + partCls(n, 'mdprevhead', 'ppd-mdprevhead') + ' style="' + esc(headCss) + '">' + headInner + '</div>' +
                '<div style="overflow:hidden"><table ' + partCls(n, 'mdprevgrid', 'ppd-mdprevgrid') + ' style="width:100%;border-collapse:collapse;font-size:11px;"><thead><tr>' + head + '</tr></thead><tbody>' + body + '</tbody></table></div>' +
            '</div></div>';
    }

    /*
       The Designer grid is a 3-row TEMPLATE (not sample records):
         row 1 = field names   (thead)   -> part head:<field>
         row 2 = field values  (tbody)   -> part cell:<field>
         row 3 = aggregates    (tfoot)   -> part foot:<field>
       A leftmost gutter labels each row and selects the whole row level
       (parts: header / body / footer). Every cell is independently selectable.
    */
    function gridRender(n, ctx) {
        var p = n.props;
        var cols = gridColumns(n, ctx).filter(function (c) { return c.visible !== false; });
        var mode = p.selectionMode || (p.selection === false ? 'none' : 'multiple');
        var showSel = mode !== 'none';
        var single = mode === 'single';
        var showActions = p.showActions !== false;
        var showNums = !!p.rowNumbers;
        var showFilters = !!p.headerFilters;
        var footerOff = p.showTotals === false;
        // Only fields flagged "Show on Filter Panel" (Fields module → Search
        // Settings) get a filter input; if none are flagged, fall back to all.
        var fpNames = ((ctx && ctx.fields) || []).filter(function (f) { return f.filterPanel; }).map(function (f) { return f.name; });
        function onFilterPanel(field) { return !fpNames.length || fpNames.indexOf(field) !== -1; }
        var acts = actionItems(p);
        function pin(c) { return c.pin === 'left' ? ' pin-left' : (c.pin === 'right' ? ' pin-right' : ''); }

        function headCells() {
            return cols.map(function (c) {
                var s = levelStyle(n, c, 'header');
                return '<th ' + partCls(n, 'head:' + c.field, pin(c).trim()) + styleStr(s) + '>' +
                    (c.pin ? '<i class="bi bi-pin-angle-fill ppd-pin"></i> ' : '') + esc(c.label || title(c.field)) +
                    (c.sortable !== false ? SORT_ICO : '') + '</th>';
            }).join('');
        }
        function filterCells() {
            return cols.map(function (c) {
                if (!onFilterPanel(c.field)) return '<td ' + partCls(n, 'filter:' + c.field, 'ppd-grid-filter' + pin(c)) + '></td>';
                var fc = c.filterConfig || {}; var t = fc.type || 'text';
                var inner = t === 'select' ? '<select disabled><option>All</option></select>'
                    : t === 'daterange' ? '<span class="rng"><input disabled placeholder="from"><input disabled placeholder="to"></span>'
                    : '<input disabled type="' + (t === 'number' || t === 'date' ? t : 'text') + '" placeholder="' + esc(fc.placeholder || 'Filter…') + '">';
                return '<td ' + partCls(n, 'filter:' + c.field, 'ppd-grid-filter' + pin(c)) + '>' + inner + '</td>';
            }).join('');
        }
        function valueCells() {
            return cols.map(function (c) {
                var s = levelStyle(n, c, 'cell');
                // the field placeholder inside the value cell is itself selectable (field:<col>)
                var fs = c.fieldStyle || {};
                var fstyle = (fs.background ? 'background:' + fs.background + ';' : '') + (fs.color ? 'color:' + fs.color + ';' : '') + (fs.bold ? 'font-weight:700;' : '');
                var vc = c.viewControl || '';
                var chip = vc ? '<span class="ppd-fieldctl-chip" title="View control: ' + esc(vc) + '">' + icon('bi-ui-checks-grid') + esc(vc) + '</span>' : '';
                return '<td ' + partCls(n, 'cell:' + c.field, pin(c).trim()) + styleStr(s) + '>' +
                    '<span ' + partCls(n, 'field:' + c.field, 'ppd-field-token') + (fstyle ? ' style="' + esc(fstyle) + '"' : '') + '>' +
                    (s.format ? esc(s.format) + ': ' : '') + esc(c.field) + chip + '</span></td>';
            }).join('');
        }
        function aggCells() {
            return cols.map(function (c) {
                var s = levelStyle(n, c, 'footer');
                var label = s.text || AGG[s.aggregate] || '';
                return '<td ' + partCls(n, 'foot:' + c.field, ('ppd-foot-cell ' + pin(c)).trim()) + styleStr(s) + '>' + (label ? '<span class="ppd-agg">' + esc(label) + '</span>' : '<span class="ppd-agg muted">—</span>') + '</td>';
            }).join('');
        }
        var actionsCell = '<td class="ppd-grid-actcol"><span class="ppd-row-actions">' + acts.map(function (m) {
            var hasIcon = m.displayMode !== 'label';
            var hasLabel = m.displayMode === 'label' || m.displayMode === 'icon_label';
            var hid = m.visible === false ? ' ppd-part-hidden' : '';
            return '<span ' + partCls(n, 'action:' + m.key, 'ppd-act ' + (hasLabel ? 'has-label ' : '') + (m.danger ? 'danger' : '') + hid) + ' title="' + esc(m.label) + '">' +
                (hasIcon ? icon(m.icon) : '') + (hasLabel ? '<span>' + esc(m.label) + '</span>' : '') + '</span>';
        }).join('') + '</span></td>';
        function selCell(tag) { return showSel ? '<' + tag + ' class="ppd-grid-check">' + (single ? '<span class="ppd-radio"></span>' : '<input type="checkbox" disabled>') + '</' + tag + '>' : ''; }
        function numCell(tag, v) { return showNums ? '<' + tag + ' class="ppd-grid-num">' + esc(v) + '</' + tag + '>' : ''; }

        // Master–Detail details column (Tables → Relations), WYSIWYG with the
        // generated list. Header + each link are selectable parts.
        var rels = detailRels();
        var dc = p.detailsCol || {};
        var showDetails = rels.length > 0 && !dc.hidden;
        var mdSingle = rels.length > 0 && (rels[0].details_link_mode || 'single') === 'single';
        var mdRels = mdSingle ? [rels[0]] : rels;
        var mdEnd = (dc.position || 'start') === 'end';
        function mdHeadCell() {
            if (!showDetails) return '';
            var st = (dc.width ? 'width:' + dc.width + ';' : '') + (dc.align ? 'text-align:' + dc.align + ';' : '');
            return '<th ' + partCls(n, 'mdhead', 'ppd-grid-mdcol') + (st ? ' style="' + esc(st) + '"' : '') + '>' + esc(dc.label || 'Details') + '</th>';
        }
        function mdBodyCell() {
            if (!showDetails) return '';
            var st = dc.align ? ' style="text-align:' + esc(dc.align) + ';"' : '';
            return '<td class="ppd-grid-mdcol"' + st + '>' + mdRels.map(function (r, i) {
                return mdLinkHtml(n, dc, r, mdSingle, [3, 1, 2][i % 3]);
            }).join(' ') + '</td>';
        }
        function mdBlank(tag) { return showDetails ? '<' + tag + ' class="ppd-grid-mdcol"></' + tag + '>' : ''; }

        var rules = p.conditionalRules || [];
        var cond = rules.length ? '<div class="ppd-cond-note">' + icon('bi-lightning-charge-fill') + ' ' + rules.length + ' conditional rule' + (rules.length > 1 ? 's' : '') + ' · matching rows/cells are coloured in the generated app</div>' : '';
        // Grouping hint (List settings → Group rows by): the generated list folds
        // rows into collapsible groups with per-group counts (+ subtotals on any
        // column that has a footer aggregate). Shown here so the canvas matches.
        var _gb = curListSettings().groupBy;
        var _hasAgg = cols.some(function (c) { return c.footer && c.footer.aggregate; });
        var grp = _gb ? '<div class="ppd-cond-note">' + icon('bi-collection') + ' Rows grouped by <b>' + esc(_gb) + '</b> at runtime — collapsible groups with per-group counts' + (_hasAgg ? ' &amp; subtotals' : '') + '</div>' : '';

        var tcls = ['ppd-grid', 'dens-' + (p.density || 'compact')];
        if (p.striped) tcls.push('is-striped');
        if (p.hover !== false) tcls.push('is-hover');
        if (p.bordered !== false) tcls.push('is-bordered');
        if (p.stickyHeader) tcls.push('sticky-head');
        tcls.push(p.wrapText ? 'is-wrap' : 'is-nowrap');

        return grp + cond + '<div class="ppd-table-wrap' + (p.responsive === 'stack' ? ' resp-stack' : '') + '"><table class="' + tcls.join(' ') + '">' +
            (p.caption ? '<caption ' + part(n, 'caption') + '>' + esc(p.caption) + '</caption>' : '') +
            '<thead>' +
                '<tr>' +
                    '<th ' + partCls(n, 'header', 'ppd-rowlabel') + ' title="Header row">' + icon('bi-type') + ' Field name</th>' +
                    numCell('th', '#') + selCell('th') + (mdEnd ? '' : mdHeadCell()) + headCells() + (mdEnd ? mdHeadCell() : '') +
                    (showActions ? '<th ' + part(n, 'actionsHeader') + ' class="ppd-grid-actcol">Actions</th>' : '') +
                '</tr>' +
                (showFilters ? '<tr class="ppd-filter-row"><td class="ppd-rowlabel">' + icon('bi-funnel') + ' Filters</td>' + numCell('td', '') + (showSel ? '<td class="ppd-grid-check"></td>' : '') + (mdEnd ? '' : mdBlank('td')) + filterCells() + (mdEnd ? mdBlank('td') : '') + (showActions ? '<td class="ppd-grid-actcol"></td>' : '') + '</tr>' : '') +
            '</thead>' +
            '<tbody><tr>' +
                '<td ' + partCls(n, 'body', 'ppd-rowlabel') + ' title="Field/value row">' + icon('bi-input-cursor') + ' Field value</td>' +
                numCell('td', '1') + selCell('td') + (mdEnd ? '' : mdBodyCell()) + valueCells() + (mdEnd ? mdBodyCell() : '') +
                (showActions ? actionsCell : '') +
            '</tr></tbody>' +
            '<tfoot><tr class="' + (footerOff ? 'ppd-foot-off' : '') + '">' +
                '<td ' + partCls(n, 'footer', 'ppd-rowlabel') + ' title="Aggregate row">' + icon('bi-calculator') + ' Aggregate</td>' +
                numCell('td', '') + (showSel ? '<td class="ppd-grid-check"></td>' : '') + (mdEnd ? '' : mdBlank('td')) + aggCells() + (mdEnd ? mdBlank('td') : '') +
                (showActions ? '<td class="ppd-grid-actcol"></td>' : '') +
            '</tr></tfoot>' +
            '</table></div>' +
            // Master–Detail: the expandable child preview panel (WYSIWYG + selectable)
            (showDetails && mdRels.length ? mdPreviewPanel(n, dc, mdRels) : '');
    }
    var gridDef = {
        label: 'Data Grid', icon: 'bi-table', category: 'Data',
        defaults: function () { return { selectionMode: 'multiple', selection: true, showActions: true, showTotals: true, density: 'compact', striped: true, hover: true, bordered: true, stickyHeader: true, rowNumbers: false, headerFilters: false, wrapText: false, responsive: 'scroll', actions: ['view', 'edit', 'delete'], caption: '', rows: {}, conditionalRules: [] }; },
        render: gridRender,
        schema: function (n) {
            var groups = [ generalGroup([
                { key: 'caption', label: 'Caption', type: 'text' },
                { key: 'emptyText', label: 'Empty-state text', type: 'text' },
                { key: 'density', label: 'Density', type: 'select', options: ['compact', 'normal', 'comfortable'] }
            ]), { title: 'Behaviour', fields: [
                { key: 'selectionMode', label: 'Selection', type: 'select', options: ['none', 'single', 'multiple'] },
                { key: 'showActions', label: 'Row actions', type: 'bool' },
                { key: 'showTotals', label: 'Aggregate row', type: 'bool' },
                { key: 'aggregateScope', label: 'Aggregate over', type: 'select', options: ['all records', 'displayed records'] },
                { key: 'headerFilters', label: 'Filter row', type: 'bool' },
                { key: 'rowNumbers', label: 'Row numbers', type: 'bool' },
                { key: 'actions', label: 'Actions', type: 'csv' }
            ] }, { title: 'Appearance', fields: [
                { key: 'striped', label: 'Striped rows', type: 'bool' },
                { key: 'hover', label: 'Hover highlight', type: 'bool' },
                { key: 'bordered', label: 'Bordered', type: 'bool' },
                { key: 'stickyHeader', label: 'Sticky header', type: 'bool' },
                { key: 'wrapText', label: 'Wrap text', type: 'bool' },
                { key: 'responsive', label: 'Responsive', type: 'select', options: ['scroll', 'stack'] }
            ] }, { title: 'Columns', fields: [{ key: 'columns', label: 'Columns', type: 'columns' }] },
            { title: 'Conditional formatting', fields: [{ key: 'conditionalRules', label: 'Rules', type: 'conditional' }] },
            styleGroup() ];
            return groups;
        }
    };
    def('table-grid', gridDef);
    def('grid', gridDef);

    /* ---------------- card gallery (records as cards) ---------------- */
    // A card part's style object → an inline CSS string. Shared shape with the
    // generated app (pp_card_part_style in BuildWriter) so design == output.
    // Selectable card parts (data-ppd-part): cbox, cmedia, chead, ctitle, csub,
    // cbadge, cbody, cfoot.
    // Flex card parts (header/footer/action buttons) align via justify-content;
    // text parts align via text-align.
    var CARD_FLEX_PARTS = { chead: 1, cfoot: 1 };
    function cardPartCss(s, key) {
        if (!s || typeof s !== 'object') return '';
        var o = [];
        if (s.color) o.push('color:' + s.color);
        if (s.background) o.push('background:' + s.background);
        if (s.font) o.push('font-family:' + s.font);
        if (s.fontSize) o.push('font-size:' + s.fontSize);
        if (s.fontWeight) o.push('font-weight:' + s.fontWeight);
        if (s.align) {
            if (CARD_FLEX_PARTS[key] || String(key || '').indexOf('cact') === 0) {
                o.push('justify-content:' + ({ left: 'flex-start', center: 'center', right: 'flex-end' }[s.align] || s.align));
            } else o.push('text-align:' + s.align);
        }
        if (s.padding) o.push('padding:' + s.padding);
        if (s.borderStyle || s.borderWidth || s.borderColor) o.push('border:' + (s.borderWidth || '1px') + ' ' + (s.borderStyle || 'solid') + ' ' + (s.borderColor || 'currentColor'));
        if (s.borderRadius) o.push('border-radius:' + s.borderRadius);
        if (s.customCss) o.push(String(s.customCss));
        return o.join(';');
    }
    // WYSIWYG card preview — emits the SAME .pp-card markup the generated app
    // renders (renderCards in core/Build/BuildWriter.php); the canvas mirrors that
    // CSS (.ppd-canvas .pp-card* in designer.css) so what you design is what ships.
    // Field labels stand in for real record values at design time.
    function cardGridRender(n, ctx) {
        var p = n.props || {};
        var cols = (p.columns && p.columns.length) ? p.columns
            : ((ctx && ctx.fields && ctx.fields.length)
                ? ctx.fields.map(function (f) { return { field: f.name, label: f.label || title(f.name), visible: true }; })
                : [{ field: 'id', label: 'ID', visible: true }, { field: 'name', label: 'Name', visible: true }]);
        var vis = cols.filter(function (f) { return f.visible !== false; });
        var colMap = {}; cols.forEach(function (c) { colMap[c.field] = c; });
        function lbl(field) { var c = colMap[field]; return (c && c.label) || title(field); }
        var device = (window.PPD.store && window.PPD.store.ui) ? (window.PPD.store.ui('device') || 'desktop') : 'desktop';
        var per = device === 'mobile' ? (p.colsMobile || 1) : device === 'tablet' ? (p.colsTablet || 2) : (p.colsDesktop || 3);
        var titleField = p.titleField || (vis.filter(function (f) { return f.field !== 'id'; })[0] || vis[0] || {}).field || 'id';
        // Body = visible fields minus the ones promoted into the header.
        var promoted = [titleField, p.subtitleField, p.imageField, p.badgeField];
        var rows = vis.filter(function (f) { return promoted.indexOf(f.field) < 0; });
        var showLabels = p.showLabels !== false;
        var acts = p.showActions ? (Array.isArray(p.actions) ? p.actions : ['view', 'edit', 'delete']) : [];
        var badgeColor = '';
        if (p.badgeField && p.badgeColors && typeof p.badgeColors === 'object') {
            var bk = Object.keys(p.badgeColors);
            badgeColor = p.badgeColors['*'] || (bk.length ? p.badgeColors[bk[0]] : '');
        }
        // Per-part styling (partStyles) → inline CSS. Each part of the card is a
        // selectable .ppd-part so it can be styled individually in the inspector.
        var PS = p.partStyles || {};
        // pattr(ghost, partKey, baseClass, extraStyle) → the class + selection +
        // inline-style attributes for a card part. Only the real (non-ghost) card
        // is selectable; both get the styling so the gallery looks consistent.
        function pattr(ghost, key, baseCls, extraStyle) {
            var s = PS[key] || {};
            var css = cardPartCss(s, key);
            if (extraStyle) css = css ? (css + ';' + extraStyle) : extraStyle;
            var cls = baseCls + (s['class'] ? ' ' + esc(s['class']) : '') + (ghost ? '' : ' ppd-part');
            var sel = ghost ? '' : ' data-ppd-id="' + esc(n.id) + '" data-ppd-part="' + esc(key) + '"';
            return sel + ' class="' + cls + '"' + (css ? ' style="' + esc(css) + '"' : '');
        }
        function card(ghost) {
            var media = p.imageField
                ? '<div' + pattr(ghost, 'cmedia', 'pp-card-media pp-card-media-ph') + '>' + icon('bi-image') + '</div>'
                : '';
            var badge = p.badgeField
                ? '<span' + pattr(ghost, 'cbadge', 'pp-card-badge', badgeColor ? 'background:' + badgeColor + ';border-color:' + badgeColor + ';color:#fff' : '') + '>' + esc(lbl(p.badgeField)) + '</span>'
                : '';
            var head = '<div' + pattr(ghost, 'chead', 'pp-card-head') + '>' +
                '<div' + pattr(ghost, 'ctitle', 'pp-card-title') + '>' + esc(lbl(titleField)) + '</div>' +
                badge +
                (p.subtitleField ? '<div' + pattr(ghost, 'csub', 'pp-card-subtitle') + '>' + esc(lbl(p.subtitleField)) + '</div>' : '') +
                '</div>';
            var body = '<dl' + pattr(ghost, 'cbody', 'pp-card-body') + '>' + rows.slice(0, 6).map(function (f) {
                return '<div class="pp-card-row">' + (showLabels ? '<dt>' + esc(f.label || title(f.field)) + '</dt>' : '') + '<dd class="pp-card-ph">' + esc(f.label || title(f.field)) + '</dd></div>';
            }).join('') + '</dl>';
            var foot = acts.length ? '<div' + pattr(ghost, 'cfoot', 'pp-card-foot') + '>' + acts.map(function (a) {
                var m = (META.action[a] || { icon: 'bi-dot', label: title(a) });
                // each action button is its own selectable/stylable part (cact:<action>)
                return '<button type="button"' + pattr(ghost, 'cact:' + a, 'ppd-rcard-act' + (m.danger ? ' danger' : '')) + ' title="' + esc(m.label) + '" disabled>' + icon(m.icon) + '</button>';
            }).join('') + '</div>' : '';
            return '<div' + pattr(ghost, 'cbox', 'pp-card' + (ghost ? ' is-ghost' : '')) + '>' + media + head + body + foot + '</div>';
        }
        // one representative card is the editable design template; ghosts convey the gallery (not real records)
        var out = card(false);
        if (isDesign()) out += card(true) + card(true);
        var style = 'grid-template-columns:repeat(' + (parseInt(per, 10) || 3) + ',minmax(0,1fr))';
        return '<div class="ppd-cards density-' + esc(p.density || 'normal') + '" style="' + style + '">' + out + '</div>';
    }
    def('card-grid', {
        label: 'Card Gallery', icon: 'bi-grid-3x3-gap-fill', category: 'Data',
        defaults: function () { return { columns: [], titleField: '', subtitleField: '', imageField: '', badgeField: '', badgeColors: {}, partStyles: {}, cardRules: [], showLabels: true, showAvatar: true, showActions: true, actions: ['view', 'edit', 'delete'], colsDesktop: 3, colsTablet: 2, colsMobile: 1, density: 'normal', selection: true }; },
        render: cardGridRender,
        schema: function () {
            return [ generalGroup([
                { key: 'density', label: 'Density', type: 'select', options: ['compact', 'normal', 'comfortable'] }
            ]), { title: 'Layout', fields: [
                { key: 'colsDesktop', label: 'Cards / row · desktop', type: 'select', options: ['1', '2', '3', '4', '5', '6'] },
                { key: 'colsTablet', label: 'Cards / row · tablet', type: 'select', options: ['1', '2', '3', '4'] },
                { key: 'colsMobile', label: 'Cards / row · mobile', type: 'select', options: ['1', '2'] }
            ] }, { title: 'Card header', fields: [
                { key: 'imageField', label: 'Image field', type: 'fieldselect', placeholder: 'No image' },
                { key: 'titleField', label: 'Title field', type: 'fieldselect' },
                { key: 'subtitleField', label: 'Subtitle field', type: 'fieldselect', placeholder: 'No subtitle' },
                { key: 'badgeField', label: 'Badge field', type: 'fieldselect', placeholder: 'No badge' },
                { key: 'badgeColors', label: 'Badge colours', type: 'badgecolors', when: 'badgeField' }
            ] }, { title: 'Card body', fields: [
                { key: 'showLabels', label: 'Show field labels', type: 'bool' },
                { key: 'columns', label: 'Body fields', type: 'columns' }
            ] }, { title: 'Card actions', fields: [
                { key: 'showActions', label: 'Show action buttons', type: 'bool' },
                { key: 'actions', label: 'Actions', type: 'csv' }
            ] }, { title: 'Conditional formatting', fields: [
                { key: 'cardRules', label: 'Rules', type: 'cardrules' }
            ] },
            styleGroup() ];
        }
    });

    def('stat-card', {
        label: 'Stat Card', icon: 'bi-bar-chart', category: 'Data',
        defaults: function () { return { label: 'Total Records', icon: 'bi-database', source: 'manual', value: '1,248', agg: 'count', decimals: 0 }; },
        render: function (n) {
            var p = n.props || {};
            var dyn = p.source === 'dynamic';
            var fmt = function (num) {
                var dec = Math.max(0, Math.min(4, parseInt(p.decimals, 10) || 0)), a = Math.abs(num), s;
                if (p.abbreviate && a >= 1e12) s = (num / 1e12).toFixed(dec) + 'T';
                else if (p.abbreviate && a >= 1e9) s = (num / 1e9).toFixed(dec) + 'B';
                else if (p.abbreviate && a >= 1e6) s = (num / 1e6).toFixed(dec) + 'M';
                else if (p.abbreviate && a >= 1e3) s = (num / 1e3).toFixed(dec) + 'k';
                else { s = num.toFixed(dec); if (p.thousands) s = s.replace(/\B(?=(\d{3})+(?!\d))/g, ','); }
                return s;
            };
            var valueHtml = dyn ? (esc(p.prefix || '') + fmt(1248) + esc(p.suffix || '')) : esc(p.value || '0');
            var badge = (dyn && !p.table) ? ' <i class="bi bi-exclamation-triangle ppd-dynbadge" title="Pick a table" style="color:var(--pp-warning,#f59e0b)"></i>'
                : (dyn ? ' <i class="bi bi-database ppd-dynbadge" title="Live value from ' + esc(title(p.table)) + '"></i>' : '');
            var sub = (dyn && p.table) ? '<small class="ppd-stat-sub">' + esc(p.agg || 'count') + (p.column ? '(' + esc(p.column) + ')' : '') + ' · ' + esc(p.table) + '</small>' : '';
            var kvBag = Object.assign({}, (p.partStyles || {}).value || {});
            if (p.valueColor && !kvBag.color) kvBag.color = p.valueColor;
            if (p.valueFont && !kvBag.font) kvBag.font = p.valueFont;
            if (p.valueWeight && !kvBag.fontWeight) kvBag.fontWeight = p.valueWeight;
            var icBag = Object.assign({}, (p.partStyles || {}).icon || {});
            if (p.iconColor && !icBag.color) icBag.color = p.iconColor;
            if (p.iconBadge && p.iconBadgeColor && !icBag.background) icBag.background = p.iconBadgeColor;
            var capBag = Object.assign({}, (p.partStyles || {}).caption || {});
            if (p.captionColor && !capBag.color) capBag.color = p.captionColor;
            var shadow = ['none', 'sm', 'md', 'lg'].indexOf(p.shadow) > -1 ? p.shadow : 'sm';
            var vsize = ['sm', 'md', 'lg', 'xl'].indexOf(p.valueSize) > -1 ? p.valueSize : 'md';
            var sty = '';
            if (p.accent) sty += '--stat-accent:' + esc(p.accent) + ';';
            if (p.bg && p.bg2) { var gdir = { 'to-right': 'to right', 'to-bottom': 'to bottom', 'diagonal': '135deg' }[p.bgDir] || 'to bottom'; sty += 'background:linear-gradient(' + gdir + ',' + esc(p.bg) + ',' + esc(p.bg2) + ');'; }
            else if (p.bg) sty += 'background:' + esc(p.bg) + ';';
            if (p.textColor) sty += 'color:' + esc(p.textColor) + ';';
            if (p.borderColor) sty += 'border-color:' + esc(p.borderColor) + ';';
            if (p.radius) sty += 'border-radius:' + esc(p.radius) + ';';
            if (p.padding) sty += 'padding:' + esc(p.padding) + ';';
            if (['left', 'center', 'right'].indexOf(p.align) > -1) sty += 'text-align:' + p.align + ';';
            if (p.font) sty += 'font-family:' + esc(p.font) + ';';
            var statCls = 'ppd-stat stat-sh-' + shadow + ' stat-v-' + vsize + (p.accent ? ' has-accent' : '') + (p.hover ? ' is-hover' : '');
            return '<div class="ppd-stat-cards"><div class="' + statCls + '"' + (sty ? ' style="' + sty + '"' : '') + '>'
                + '<div' + elAttrsFrom('value', 'v', kvBag) + '>' + valueHtml + '</div>'
                + '<div' + elAttrsFrom('caption', 'l' + (p.captionUpper ? ' is-upper' : ''), capBag) + '><span' + elAttrsFrom('icon', 'ppd-elicon' + (p.iconBadge ? ' is-badge' : ''), icBag) + '>' + icon(p.icon || 'bi-database') + '</span> ' + esc(p.label || n.label) + badge + '</div>'
                + sub + '</div></div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'label', label: 'Caption', type: 'text', hint: 'The label under the big value (e.g. “Total Records”).' },
            { key: 'icon', label: 'Icon', type: 'icon', hint: 'Icon shown beside the caption.' },
            { key: 'source', label: 'Value from', type: 'radiogroup', options: ['manual', 'dynamic'], rerender: true, hint: 'Manual = you type the value. Dynamic = a live aggregate from a table.' },
            // manual
            { key: 'value', label: 'Value', type: 'text', when: { key: 'source', in: ['manual', ''] }, hint: 'The big number/text shown (e.g. “1,248”).' },
            // dynamic
            { key: 'table', label: 'Table', type: 'dbtable', when: { key: 'source', in: ['dynamic'] }, hint: 'The table to aggregate.' },
            { key: 'agg', label: 'Aggregate', type: 'select', options: ['count', 'sum', 'avg', 'min', 'max'], rerender: true, when: { key: 'source', in: ['dynamic'] }, hint: 'count = number of rows · sum/avg/min/max = over a column.' },
            { key: 'column', label: 'Column', type: 'dbcolumn', tableKey: 'table', when: function (p) { return p.source === 'dynamic' && (p.agg || 'count') !== 'count'; }, hint: 'The number column for sum / avg / min / max.' },
            { key: 'where', label: 'Filter (WHERE, optional)', type: 'text', full: true, when: { key: 'source', in: ['dynamic'] },
              placeholder: "status = 'active' AND created_at >= :startof-month",
              hint: 'Only aggregate rows matching this SQL condition (no “WHERE”). Supports SQL variables.',
              note: 'e.g. <code>status = \'active\'</code> — supports <code>:userid</code>, <code>:today</code>, <code>:startof-month</code>, <code>:field.name</code>.' },
            { key: 'prefix', label: 'Prefix', type: 'text', when: { key: 'source', in: ['dynamic'] }, hint: 'Text before the number, e.g. “$”.' },
            { key: 'suffix', label: 'Suffix', type: 'text', when: { key: 'source', in: ['dynamic'] }, hint: 'Text after the number, e.g. “k”, “ orders”.' },
            { key: 'decimals', label: 'Decimals', type: 'number', when: { key: 'source', in: ['dynamic'] }, hint: 'Digits after the decimal point (0–4).' },
            { key: 'abbreviate', label: 'Abbreviate (1.2k, 3.4M)', type: 'bool', when: { key: 'source', in: ['dynamic'] }, hint: '48,200 → 48.2k · 1,200,000 → 1.2M.' },
            { key: 'thousands', label: 'Thousands separator', type: 'bool', when: { key: 'source', in: ['dynamic'] }, hint: 'Group digits: 1204 → 1,204.' },
            { key: 'clickable', label: 'Click card → open list', type: 'bool', when: { key: 'source', in: ['dynamic'] }, hint: 'Make the whole card a link to the table’s list page.' }
        ]), { title: 'Card style', fields: [
            { key: 'bg', label: 'Background', type: 'color', hint: 'Card background colour (blank = theme surface).' },
            { key: 'bg2', label: 'Background 2 (gradient)', type: 'color', hint: 'Set a second colour to make the background a gradient.' },
            { key: 'bgDir', label: 'Gradient direction', type: 'select', options: ['to-bottom', 'to-right', 'diagonal'], hint: 'Direction of the background gradient (only used when Background 2 is set).' },
            { key: 'accent', label: 'Accent colour', type: 'color', hint: 'Colours the icon and adds a left accent bar.' },
            { key: 'shadow', label: 'Shadow', type: 'select', options: ['none', 'sm', 'md', 'lg'], hint: 'Drop-shadow depth.' },
            { key: 'hover', label: 'Lift on hover', type: 'bool', hint: 'The card raises slightly with a bigger shadow when hovered.' },
            { key: 'align', label: 'Align', type: 'select', options: ['left', 'center', 'right'], hint: 'Text alignment inside the card.' },
            { key: 'textColor', label: 'Text colour', type: 'color', hint: 'Colour of the card text (caption + value, unless overridden below).' },
            { key: 'font', label: 'Card font', type: 'font', hint: 'Font family for the whole card.' },
            { key: 'borderColor', label: 'Border colour', type: 'color', hint: 'Card border colour.' },
            { key: 'radius', label: 'Corner radius', type: 'text', placeholder: '14px', hint: 'Rounded corners, e.g. 14px or 20px.' },
            { key: 'padding', label: 'Padding', type: 'text', placeholder: '16px', hint: 'Inner spacing, e.g. 16px or 20px 24px.' }
        ] }, { title: 'Value style', fields: [
            { key: 'valueColor', label: 'Value colour', type: 'color', hint: 'Colour of the big number (overrides Text colour).' },
            { key: 'valueSize', label: 'Value size', type: 'select', options: ['sm', 'md', 'lg', 'xl'], hint: 'Size of the big number.' },
            { key: 'valueFont', label: 'Value font', type: 'font', hint: 'Font family for the value (overrides the card font).' },
            { key: 'valueWeight', label: 'Value weight', type: 'select', options: ['normal', '500', '600', '700', '800'], hint: 'Boldness of the value.' }
        ] }, { title: 'Icon & caption', fields: [
            { key: 'iconBadge', label: 'Icon badge', type: 'bool', hint: 'Show the icon inside a rounded coloured badge (a modern KPI look).' },
            { key: 'iconBadgeColor', label: 'Badge colour', type: 'color', when: 'iconBadge', hint: 'Background colour of the icon badge.' },
            { key: 'iconColor', label: 'Icon colour', type: 'color', hint: 'Colour of the icon glyph.' },
            { key: 'captionColor', label: 'Caption colour', type: 'color', hint: 'Colour of the caption text.' },
            { key: 'captionUpper', label: 'Uppercase caption', type: 'bool', hint: 'Small-caps style caption (uppercase + letter-spacing).' }
        ] }, styleGroup() ]; }
    });
    def('chart', {
        label: 'Chart', icon: 'bi-bar-chart-line', category: 'Data',
        // Every display/interactivity toggle is in defaults() with the SAME value the
        // build assumes (ComponentRenderer 'chart': showGrid/showLegend/animate ?? true,
        // the rest ?? false) so a fresh drag, the inspector checkbox and the generated
        // app never disagree — and backfill can seed them into older saved charts.
        defaults: function () { return { chartType: 'bar', agg: 'count', limit: 10, height: 260, showGrid: true, showLegend: true, animate: true, showValues: false, colorful: false, smooth: false, donutTotal: false, depth: false, abbreviate: false, drill: false }; },
        render: function (n) {
            var p = n.props || {};
            var bound = p.table && p.labelColumn;
            var sub = bound
                ? esc(p.table) + ' · ' + esc(p.agg || 'count') + (p.valueColumn ? '(' + esc(p.valueColumn) + ')' : '') + ' by ' + esc(p.labelColumn)
                : 'Configure: pick a table + label column';
            var minis = {
                bar: '<rect x="4" y="18" width="8" height="18" rx="1.5"/><rect x="16" y="8" width="8" height="28" rx="1.5"/><rect x="28" y="22" width="8" height="14" rx="1.5"/><rect x="40" y="12" width="8" height="24" rx="1.5"/>',
                hbar: '<rect x="4" y="5" width="30" height="7" rx="1.5"/><rect x="4" y="16" width="48" height="7" rx="1.5"/><rect x="4" y="27" width="20" height="7" rx="1.5"/>',
                line: '<polyline points="4,30 16,18 28,24 40,8 52,14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>',
                area: '<path d="M4 30 L16 18 L28 24 L40 8 L52 14 L52 36 L4 36 Z" opacity=".55"/>',
                pie: '<circle cx="28" cy="20" r="15" opacity=".3"/><path d="M28 20 L28 5 A15 15 0 0 1 41 27 Z"/>',
                doughnut: '<circle cx="28" cy="20" r="12" fill="none" stroke="currentColor" stroke-width="6" opacity=".3"/><path d="M28 8 A12 12 0 0 1 39 25" fill="none" stroke="currentColor" stroke-width="6" stroke-linecap="round"/>'
            };
            return '<div class="ppd-chart" style="display:flex;flex-direction:column;gap:6px;padding:14px;border:1px solid var(--ppd-border,#e2e8f0);border-radius:12px;background:var(--ppd-surface,#fff);">'
                + (p.title ? '<strong style="font-size:13px;">' + esc(p.title) + '</strong>' : '')
                + (p.subtitle ? '<small style="color:#94a3b8;margin-top:-4px;">' + esc(p.subtitle) + '</small>' : '')
                + '<svg viewBox="0 0 56 40" style="width:120px;height:74px;color:' + esc(p.color || '#2563eb') + ';fill:currentColor;">' + (minis[p.chartType] || minis.bar) + '</svg>'
                + '<small style="color:#64748b;">' + icon(bound ? 'bi-database-check' : 'bi-exclamation-circle') + ' ' + sub + '</small></div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'title', label: 'Title', type: 'text', hint: 'Heading above the chart.' },
            { key: 'subtitle', label: 'Subtitle', type: 'text', hint: 'Small grey text under the title (optional).' },
            { key: 'chartType', label: 'Type', type: 'select', options: ['bar', 'hbar', 'line', 'area', 'pie', 'doughnut'], hint: 'bar = vertical bars · hbar = horizontal bars (best for long labels) · line/area · pie/doughnut.' },
            { key: 'table', label: 'Table', type: 'dbtable', hint: 'The table to chart.' },
            { key: 'labelColumn', label: 'Group by (labels)', type: 'dbcolumn', tableKey: 'table', hint: 'Column whose distinct values become the categories (x-axis / slices).' },
            { key: 'agg', label: 'Aggregate', type: 'select', options: ['count', 'sum', 'avg', 'min', 'max'], hint: 'count = rows per group · sum/avg/min/max = over the value column.' },
            { key: 'valueColumn', label: 'Value column (for sum/avg…)', type: 'dbcolumn', tableKey: 'table', hint: 'The number column aggregated for sum / avg / min / max.' },
            { key: 'where', label: 'Where (condition)', type: 'text', full: true, placeholder: "status = 'active'  ·  created >= :daysago.30  ·  owner_id = :userid", hint: 'Filter which rows are charted. Supports SQL variables.', note: 'Supports SQL variables — <code>:userid</code>, <code>:today</code>, <code>:daysago.30</code>, <code>:session.key</code>.' },
            { key: 'orderBy', label: 'Sort', type: 'select', options: ['value DESC', 'value ASC', 'label ASC', 'label DESC'], hint: 'Order of the bars/slices.' },
            { key: 'limit', label: 'Max groups', type: 'number', hint: 'Cap the number of categories shown (1–100).' },
            { key: 'height', label: 'Height (px)', type: 'number', hint: 'Chart height in pixels.' },
            { key: 'color', label: 'Color', type: 'color', hint: 'Bar/line colour (pie/doughnut always use the palette; “Colourful” below applies it to bars too).' },
            { key: 'drill', label: 'Drill-down: click a bar/slice to open the list filtered to it', type: 'bool', hint: 'Clicking a bar/slice/point opens the table’s list filtered to that group.' }
        ]), { title: 'Display', fields: [
            { key: 'showValues', label: 'Show data labels', type: 'bool', hint: 'Print each value on the bars / points (percentages on pie).' },
            { key: 'colorful', label: 'Colourful bars', type: 'bool', hint: 'Give each bar/point its own palette colour (instead of one colour).' },
            { key: 'showGrid', label: 'Grid lines', type: 'bool', def: true, hint: 'Show the axis grid lines + value labels.' },
            { key: 'smooth', label: 'Smooth line', type: 'bool', hint: 'Curve the line/area instead of straight segments.' },
            { key: 'showLegend', label: 'Legend (pie/doughnut)', type: 'bool', def: true, hint: 'Show the coloured legend under a pie/doughnut.' },
            { key: 'donutTotal', label: 'Total in doughnut centre', type: 'bool', hint: 'Print the grand total in the middle of a doughnut.' }
        ] }, { title: 'Interactivity', fields: [
            { key: 'animate', label: 'Animate on load', type: 'bool', def: true, hint: 'Bars grow, lines draw, slices fade in on first paint (skipped if the viewer prefers reduced motion).' },
            { key: 'depth', label: '3D depth (shadow + lift)', type: 'bool', hint: 'Soft drop-shadow + a richer gradient fill, and marks lift on hover — a premium 2.5D look.' }
        ] }, { title: 'Value format', fields: [
            { key: 'prefix', label: 'Prefix', type: 'text', placeholder: '$', hint: 'Text before values in labels/tooltips, e.g. “$”.' },
            { key: 'suffix', label: 'Suffix', type: 'text', placeholder: '%', hint: 'Text after values, e.g. “%”, “ pcs”.' },
            { key: 'abbreviate', label: 'Abbreviate (1.2k, 3.4M)', type: 'bool', hint: 'Shorten big numbers on axes/labels.' }
        ] }, styleGroup() ]; }
    });
    def('kpi-stat', {
        label: 'KPI Stat', icon: 'bi-speedometer2', category: 'Data',
        defaults: function () { return { label: 'Total records', icon: 'bi-database', agg: 'count', shadow: 'sm', valueSize: 'lg' }; },
        render: function (n) {
            var p = n.props || {};
            var bound = !!p.table;
            var sub = bound ? esc(p.agg || 'count') + (p.column ? '(' + esc(p.column) + ')' : '') + ' · ' + esc(p.table) : 'Pick a table…';
            var kvBag = Object.assign({}, (p.partStyles || {}).value || {});
            var vc = p.valueColor || p.color;
            if (vc && !kvBag.color) kvBag.color = vc;
            if (p.valueFont && !kvBag.font) kvBag.font = p.valueFont;
            if (p.valueWeight && !kvBag.fontWeight) kvBag.fontWeight = p.valueWeight;
            var icBag = Object.assign({}, (p.partStyles || {}).icon || {});
            if (p.iconColor && !icBag.color) icBag.color = p.iconColor;
            if (p.iconBadge && p.iconBadgeColor && !icBag.background) icBag.background = p.iconBadgeColor;
            var capBag = Object.assign({}, (p.partStyles || {}).caption || {});
            if (p.captionColor && !capBag.color) capBag.color = p.captionColor;
            var shadow = ['none', 'sm', 'md', 'lg'].indexOf(p.shadow) > -1 ? p.shadow : 'sm';
            var vsize = ['sm', 'md', 'lg', 'xl'].indexOf(p.valueSize) > -1 ? p.valueSize : 'lg';
            var sty = '';
            if (p.accent) sty += '--stat-accent:' + esc(p.accent) + ';';
            if (p.bg && p.bg2) { var gdir = { 'to-right': 'to right', 'to-bottom': 'to bottom', 'diagonal': '135deg' }[p.bgDir] || 'to bottom'; sty += 'background:linear-gradient(' + gdir + ',' + esc(p.bg) + ',' + esc(p.bg2) + ');'; }
            else if (p.bg) sty += 'background:' + esc(p.bg) + ';';
            if (p.borderColor) sty += 'border-color:' + esc(p.borderColor) + ';';
            if (p.radius) sty += 'border-radius:' + esc(p.radius) + ';';
            if (p.padding) sty += 'padding:' + esc(p.padding) + ';';
            if (['left', 'center', 'right'].indexOf(p.align) > -1) sty += 'text-align:' + p.align + ';';
            if (p.font) sty += 'font-family:' + esc(p.font) + ';';
            var statCls = 'ppd-stat stat-sh-' + shadow + ' stat-v-' + vsize + (p.accent ? ' has-accent' : '') + (p.hover ? ' is-hover' : '');
            return '<div class="ppd-stat-cards"><div class="' + statCls + '"' + (sty ? ' style="' + sty + '"' : '') + '>'
                + '<div' + elAttrsFrom('value', 'v', kvBag) + '>'
                + esc(p.prefix || '') + (bound ? '1,248' : '—') + esc(p.suffix || '') + '</div>'
                + '<div' + elAttrsFrom('caption', 'l' + (p.captionUpper ? ' is-upper' : ''), capBag) + '><span' + elAttrsFrom('icon', 'ppd-elicon' + (p.iconBadge ? ' is-badge' : ''), icBag) + '>' + icon(p.icon || 'bi-database') + '</span> ' + esc(p.label || 'KPI') + '</div>'
                + '<small class="ppd-stat-sub">' + sub + '</small></div></div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'label', label: 'Label', type: 'text', hint: 'Caption under the value (e.g. “Total records”).' },
            { key: 'icon', label: 'Icon', type: 'icon', hint: 'Icon beside the caption.' },
            { key: 'table', label: 'Table', type: 'dbtable', hint: 'The table to aggregate.' },
            { key: 'agg', label: 'Aggregate', type: 'select', options: ['count', 'sum', 'avg', 'min', 'max'], rerender: true, hint: 'count = rows · sum/avg/min/max = over a column.' },
            { key: 'column', label: 'Column', type: 'dbcolumn', tableKey: 'table', when: function (p) { return p.agg && p.agg !== 'count'; }, hint: 'Number column for sum / avg / min / max.' },
            { key: 'where', label: 'Filter (WHERE, optional)', type: 'text', full: true, placeholder: "status = 'active' AND created_at >= :startof-month", hint: 'Only aggregate rows matching this SQL condition. Supports SQL variables.', note: 'Supports SQL variables — <code>:userid</code>, <code>:today</code>, <code>:daysago.30</code>, <code>:field.name</code>.' },
            { key: 'prefix', label: 'Prefix', type: 'text', placeholder: '$', hint: 'Text before the number, e.g. “$”.' },
            { key: 'suffix', label: 'Suffix', type: 'text', placeholder: 'pcs', hint: 'Text after the number, e.g. “pcs”, “%”.' }
        ]), { title: 'Card style', fields: [
            { key: 'bg', label: 'Background', type: 'color', hint: 'Card background colour (blank = theme surface).' },
            { key: 'bg2', label: 'Background 2 (gradient)', type: 'color', hint: 'Set a second colour to make the background a gradient.' },
            { key: 'bgDir', label: 'Gradient direction', type: 'select', options: ['to-bottom', 'to-right', 'diagonal'], hint: 'Direction of the background gradient (only used when Background 2 is set).' },
            { key: 'accent', label: 'Accent colour', type: 'color', hint: 'Colours the icon and adds a left accent bar.' },
            { key: 'shadow', label: 'Shadow', type: 'select', options: ['none', 'sm', 'md', 'lg'], hint: 'Drop-shadow depth.' },
            { key: 'hover', label: 'Lift on hover', type: 'bool', hint: 'The card raises slightly with a bigger shadow when hovered.' },
            { key: 'align', label: 'Align', type: 'select', options: ['left', 'center', 'right'], hint: 'Text alignment inside the card.' },
            { key: 'font', label: 'Card font', type: 'font', hint: 'Font family for the whole card.' },
            { key: 'borderColor', label: 'Border colour', type: 'color', hint: 'Card border colour.' },
            { key: 'radius', label: 'Corner radius', type: 'text', placeholder: '14px', hint: 'Rounded corners, e.g. 14px or 20px.' },
            { key: 'padding', label: 'Padding', type: 'text', placeholder: '16px', hint: 'Inner spacing, e.g. 16px or 20px 24px.' }
        ] }, { title: 'Value style', fields: [
            { key: 'valueColor', label: 'Value colour', type: 'color', hint: 'Colour of the big number.' },
            { key: 'valueSize', label: 'Value size', type: 'select', options: ['sm', 'md', 'lg', 'xl'], hint: 'Size of the big number.' },
            { key: 'valueFont', label: 'Value font', type: 'font', hint: 'Font family for the value (overrides the card font).' },
            { key: 'valueWeight', label: 'Value weight', type: 'select', options: ['normal', '500', '600', '700', '800'], hint: 'Boldness of the value.' }
        ] }, { title: 'Icon & caption', fields: [
            { key: 'iconBadge', label: 'Icon badge', type: 'bool', hint: 'Show the icon inside a rounded coloured badge (a modern KPI look).' },
            { key: 'iconBadgeColor', label: 'Badge colour', type: 'color', when: 'iconBadge', hint: 'Background colour of the icon badge.' },
            { key: 'iconColor', label: 'Icon colour', type: 'color', hint: 'Colour of the icon glyph.' },
            { key: 'captionColor', label: 'Caption colour', type: 'color', hint: 'Colour of the caption text.' },
            { key: 'captionUpper', label: 'Uppercase caption', type: 'bool', hint: 'Small-caps style caption (uppercase + letter-spacing).' }
        ] }, styleGroup() ]; }
    });

    /* ---------------- forms ---------------- */
    // Front-end approximations of the backend field-control plugins (View/Edit).
    // Instant, offline canvas preview; the live endpoint upgrades to the exact HTML.
    function setg(s, k, d) { return (s && s[k] != null && s[k] !== '') ? s[k] : d; }
    var FC_APPROX = {
        edit: {
            TextBox: function () { return '<div class="ppd-c-inputwrap"><input disabled></div>'; },
            TextArea: function (s) { return '<textarea rows="' + (parseInt(setg(s, 'rows', 3), 10) || 3) + '" disabled></textarea>'; },
            DatePicker: function () { return '<div class="ppd-c-inputwrap">' + icon('bi-calendar3') + '<input type="text" disabled placeholder="Pick a date…"></div>'; },
            TimePicker: function () { return '<div class="ppd-c-inputwrap">' + icon('bi-clock') + '<input type="time" disabled></div>'; },
            Password: function () { return '<div class="ppd-c-inputwrap">' + icon('bi-lock') + '<input type="password" value="••••••" disabled></div>'; },
            Checkbox: function (n, s) {
                var dis = setg(s, 'state', 'normal') === 'disabled';
                var cls = 'pp-sz-' + esc(setg(s, 'size', 'md')) + ' pp-c-' + esc(setg(s, 'color', 'primary'));
                var one = function (label, on) {
                    return '<label class="pp-ctl pp-checkbox' + (dis ? ' pp-disabled' : '') + '"><input type="checkbox"' + (on ? ' checked' : '') + ' disabled><span class="pp-ctl-box"><i class="bi bi-check-lg"></i></span>' +
                        (label ? '<span class="pp-ctl-txt">' + esc(label) + '</span>' : '') + '</label>';
                };
                if (setg(s, 'mode', 'single') === 'group') {
                    var opts;
                    if (setg(s, 'source', 'manual') === 'table') {
                        var t = setg(s, 'table', '');
                        opts = t ? ['From ' + t + '…'] : ['Set lookup table…'];
                    } else {
                        opts = String(setg(s, 'options', 'Option A, Option B')).split(',').map(function (o) {
                            o = o.trim(); var ix = o.indexOf(':');
                            return ix > -1 ? o.slice(ix + 1).trim() : o;
                        }).filter(Boolean);
                    }
                    if (!opts.length) opts = ['Option A', 'Option B'];
                    return '<div class="pp-ctl-group ' + cls + (setg(s, 'layout', 'inline') === 'stacked' ? ' pp-stack' : '') + '">' +
                        opts.map(function (o, i) { return one(o, i === 0); }).join('') + '</div>';
                }
                return '<span class="' + cls + '">' + one(setg(s, 'text', ''), true) + '</span>';
            },
            RadioGroup: function (n, s) {
                var dis = setg(s, 'state', 'normal') === 'disabled';
                var opts;
                if (setg(s, 'source', 'manual') === 'table') {
                    // live preview fetches real DB values; static fallback hints the source
                    var t = setg(s, 'table', '');
                    opts = t ? ['From ' + t + '…'] : ['Set lookup table…'];
                } else {
                    opts = String(setg(s, 'options', 'Option A, Option B')).split(',').map(function (o) {
                        o = o.trim(); var ix = o.indexOf(':');
                        return ix > -1 ? o.slice(ix + 1).trim() : o;   // value:Label → show Label
                    }).filter(Boolean);
                }
                if (!opts.length) opts = ['Yes', 'No'];
                return '<div class="pp-ctl-group pp-sz-' + esc(setg(s, 'size', 'md')) + ' pp-c-' + esc(setg(s, 'color', 'primary')) + (setg(s, 'layout', 'inline') === 'stacked' ? ' pp-stack' : '') + '">' +
                    opts.map(function (o, i) {
                        return '<label class="pp-ctl pp-radio' + (dis ? ' pp-disabled' : '') + '"><input type="radio" disabled' + (i === 0 ? ' checked' : '') + '><span class="pp-ctl-box"></span><span class="pp-ctl-txt">' + esc(o) + '</span></label>';
                    }).join('') + '</div>';
            },
            Switch: function (n, s) {
                var dis = setg(s, 'state', 'normal') === 'disabled';
                return '<label class="pp-ctl pp-switch pp-v-' + esc(setg(s, 'design', 'classic')) + ' pp-sz-' + esc(setg(s, 'size', 'md')) + ' pp-c-' + esc(setg(s, 'color', 'primary')) + (dis ? ' pp-disabled' : '') + '">' +
                    '<input type="checkbox" checked disabled><span class="pp-sw">' +
                    '<span class="pp-sw-on">' + esc(setg(s, 'onText', 'ON')) + '</span><span class="pp-sw-off">' + esc(setg(s, 'offText', 'OFF')) + '</span>' +
                    '<span class="pp-sw-onic"><i class="bi ' + esc(setg(s, 'onIcon', 'bi-brightness-high')) + '"></i></span><span class="pp-sw-offic"><i class="bi ' + esc(setg(s, 'offIcon', 'bi-moon')) + '"></i></span>' +
                    '<span class="pp-sw-thumb"><i class="bi bi-check-lg ic-on"></i><i class="bi bi-x-lg ic-off"></i></span>' +
                    '</span>' +
                    (setg(s, 'sideText', 'none') === 'state' ? '<span class="pp-sw-side"><span class="on">' + esc(setg(s, 'onText', 'ON')) + '</span><span class="off">' + esc(setg(s, 'offText', 'OFF')) + '</span></span>' : '') +
                    '</label>';
            },
            Dropdown: function (n, s) {
                var variant = setg(s, 'variant', 'solid');
                var cls = 'pp-b2 pp-dd pp-bc-' + esc(setg(s, 'color', 'primary')) + ' pp-bz-' + esc(setg(s, 'size', 'md')) +
                    (variant === 'outline' ? ' is-outline' : (variant === 'light' ? ' is-light' : '')) +
                    (setg(s, 'shape', 'default') === 'rounded' ? ' is-rounded' : '') + (setg(s, 'block', '') ? ' is-block' : '');
                var lbl;
                if (setg(s, 'source', 'manual') === 'table') { var t = setg(s, 'table', ''); lbl = t ? 'From ' + t + '…' : 'Set table…'; }
                else { var o = String(setg(s, 'options', '')).split(',')[0] || ''; var ix = o.indexOf(':'); lbl = (ix > -1 ? o.slice(ix + 1) : o).trim() || setg(s, 'placeholder', '— Select —'); }
                return '<div class="' + cls + '"><select disabled><option>' + esc(lbl) + '</option></select><i class="bi bi-chevron-down"></i></div>';
            },
            ButtonToggle: function (n, s) {
                var opts;
                if (setg(s, 'source', 'manual') === 'table') { var t = setg(s, 'table', ''); opts = t ? ['From ' + t + '…'] : ['Set table…']; }
                else { opts = String(setg(s, 'options', 'Option A, Option B')).split(',').map(function (o) { o = o.trim(); var ix = o.indexOf(':'); return (ix > -1 ? o.slice(ix + 1) : o).trim(); }).filter(Boolean); }
                if (!opts.length) opts = ['Option A', 'Option B'];
                var cls = 'pp-seg pp-bc-' + esc(setg(s, 'color', 'primary')) + ' pp-bz-' + esc(setg(s, 'size', 'md')) +
                    (setg(s, 'shape', 'default') === 'rounded' ? ' is-rounded' : '') + (setg(s, 'block', '') ? ' is-block' : '');
                return '<div class="' + cls + '">' + opts.map(function (o, i) { return '<button type="button" class="pp-seg-btn' + (i === 0 ? ' is-on' : '') + '">' + esc(o) + '</button>'; }).join('') + '</div>';
            },
            StarRating: function (n, s) { var mx = parseInt(setg(s, 'max', 5), 10) || 5, v = 3, o = ''; for (var i = 1; i <= mx; i++) o += icon(i <= v ? 'bi-star-fill' : 'bi-star'); return '<div class="ppd-rating">' + o + '</div>'; },
            FileImage: function (n, s) {
                var img = setg(s, 'imageOnly', '') ? 'images' : 'files';
                var multi = setg(s, 'multiple', '') ? ' · multiple' : '';
                return '<div class="ppd-c-dropzone">' + icon('bi-cloud-arrow-up') +
                    '<span><b>Drag &amp; drop</b> ' + img + ', or <u>browse</u></span>' +
                    '<small>Uploads to your chosen storage' + multi + '</small></div>';
            },
            LookupWizard: function () { return '<div class="ppd-c-inputwrap">' + icon('bi-search') + '<select disabled><option>— Lookup —</option></select></div>'; },
            ReadOnlyControl: function () { return '<div class="ppd-c-viewval">Read-only value</div>'; }
        },
        view: {
            PlainText: function () { return '<div class="ppd-c-viewval">Sample value</div>'; },
            Badge: function (n, s) {
                var variant = setg(s, 'variant', 'solid');
                var shape = variant === 'button' ? 'pp-b2' : 'pp-bdg';
                var cls = shape + ' pp-bc-' + esc(setg(s, 'color', 'primary')) + ' pp-bz-' + esc(setg(s, 'size', 'md')) +
                    (variant === 'outline' ? ' is-outline' : (variant === 'light' ? ' is-light' : '')) +
                    (setg(s, 'shape', 'rounded') === 'rounded' ? ' is-rounded' : '') + (setg(s, 'dot', '') && shape === 'pp-bdg' ? ' is-dot' : '');
                var ic = setg(s, 'icon', '') ? '<i class="bi ' + esc(setg(s, 'icon', '')) + '"></i>' : '';
                return '<span class="' + cls + '">' + ic + 'Sample</span>';
            },
            Currency: function (s) { return '<div class="ppd-c-viewval">' + esc(setg(s, 'symbol', '$')) + '1,234.50</div>'; },
            Number: function () { return '<div class="ppd-c-viewval">1,234</div>'; },
            Percent: function () { return '<div class="ppd-c-viewval">42%</div>'; },
            ShortDate: function () { return '<div class="ppd-c-viewval">07/02/2026</div>'; },
            MediumDate: function () { return '<div class="ppd-c-viewval">Jul 2, 2026</div>'; },
            LongDate: function () { return '<div class="ppd-c-viewval">July 2, 2026</div>'; },
            PhoneNumber: function () { return '<div class="ppd-c-viewval"><a>+1 (555) 010-2938</a></div>'; },
            EmailHyperlink: function () { return '<div class="ppd-c-viewval"><a>' + icon('bi-envelope') + ' user@example.com</a></div>'; },
            Hyperlink: function () { return '<div class="ppd-c-viewval"><a>' + icon('bi-link-45deg') + ' example.com</a></div>'; },
            Image: function () { return '<div class="ppd-fc-img">' + icon('bi-image') + '</div>'; },
            QRCode: function () { return '<div class="ppd-qr">' + icon('bi-qr-code') + '</div>'; },
            HTML: function () { return '<div class="ppd-c-viewval"><strong>Rich</strong> <em>HTML</em> value</div>'; },
            Audio: function () { return '<div class="ppd-c-viewval">' + icon('bi-play-circle') + ' audio.mp3</div>'; },
            Video: function () { return '<div class="ppd-media" style="min-height:70px">' + icon('bi-play-circle') + '</div>'; },
            File: function () { return '<div class="ppd-c-viewval">' + icon('bi-paperclip') + ' document.pdf</div>'; }
        }
    };
    function fieldControlOf(p) {
        var cat = p.mode === 'view' ? 'view' : 'edit';
        var id = cat === 'view' ? (p.viewControl || '') : (p.editControl || '');
        var settings = (cat === 'view' ? p.viewSettings : p.editSettings) || {};
        // Fall back to the FIELD-LEVEL control (set once via the double-click
        // "Field controls" modal, stored in the fields config) when this component
        // carries no per-page override — so the canvas preview matches the build,
        // which reads exactly this field-level assignment on every page.
        if (!id && p.field) {
            try {
                var st = PPD.store;
                var pg = (PPD.model && PPD.model.activePage && PPD.model.activePage()) || {};
                var tbl = pg.table || '';
                // Map the active page type to a per-page override key (pageAs).
                var pageKey = ({ list: 'list', add: 'add', edit: 'edit', view: 'view', print: 'view', search: 'search' })[pg.type || ''] || '';
                var arr = (st && st.fieldsFor && tbl) ? st.fieldsFor(tbl) : [];
                for (var i = 0; i < arr.length; i++) {
                    if (arr[i].name === p.field) {
                        var over = pageKey && arr[i].pageAs && arr[i].pageAs[pageKey];
                        if (over && over.type) { id = over.type; settings = over.settings || {}; }
                        else {
                            id = cat === 'view' ? (arr[i].viewControl || '') : (arr[i].editControl || '');
                            if (id) settings = (cat === 'view' ? arr[i].viewSettings : arr[i].editSettings) || {};
                        }
                        break;
                    }
                }
            } catch (e) {}
        }
        return { cat: cat, id: id, settings: settings };
    }
    // Canvas mirror of BuildWriter::fieldShell — label style, pre/post icons,
    // label width/gap.
    function cssv(v) { return String(v == null ? '' : v).replace(/[{};"<>]/g, '').trim(); }
    function fieldShell(pv, label, reqMark, inputHtml, help, labelStyle, iconPre, iconPost, canFloat, labelWidth, labelGap) {
        var pre = iconPre ? '<span class="pp-fld-ic pre"><i class="bi ' + esc(iconPre.replace(/^bi /, '')) + '"></i></span>' : '';
        var post = iconPost ? '<span class="pp-fld-ic post"><i class="bi ' + esc(iconPost.replace(/^bi /, '')) + '"></i></span>' : '';
        var wrapCls = 'pp-fld-wrap' + (pre ? ' has-pre' : '') + (post ? ' has-post' : '');
        var inner = pre + inputHtml + post;
        var labelTag = '<label>' + label + reqMark + '</label>';
        var vars = (labelWidth ? '--lbl-w:' + esc(cssv(labelWidth)) + ';' : '') + (labelGap ? '--lbl-gap:' + esc(cssv(labelGap)) + ';' : '');
        var st = vars ? ' style="' + vars + '"' : '';
        var isFloat = labelStyle.indexOf('float') === 0 && canFloat;
        if (isFloat) {
            var cls = 'pp-fld is-float ' + (labelStyle === 'float-filled' ? 'is-float-filled' : 'is-float-outline');
            return '<div class="' + cls + '"' + pv + st + '><div class="' + wrapCls + '">' + inner + labelTag + '</div>' + help + '</div>';
        }
        var c = 'pp-fld' + (labelStyle === 'inline' ? ' is-inline' : '');
        return '<div class="' + c + '"' + pv + st + '>' + labelTag + '<div class="' + wrapCls + '">' + inner + '</div>' + help + '</div>';
    }
    def('field', {
        label: 'Field', icon: 'bi-input-cursor-text', category: 'Form',
        defaults: function () { return { field: '', inputType: 'text', mode: 'edit', editControl: '', editSettings: {}, viewControl: '', viewSettings: {}, required: false, validations: [], placeholder: '', help: '', defaultValue: '', autoUpdate: '', labelStyle: 'default', iconPre: '', iconPost: '', spans: { desktop: 6, tablet: 6, mobile: 12 } }; },
        render: function (n) {
            var p = n.props; var t = p.inputType || 'text';
            var reqMark = p.required ? '<span class="pp-req">*</span>' : '';
            var help = p.help ? '<small class="pp-field-help">' + esc(p.help) + '</small>' : '';
            var fc = fieldControlOf(p);
            var pv = fc.id ? ' data-ppd-fc="' + esc(fc.cat) + '" data-ppd-fc-ctrl="' + esc(fc.id) + '" data-ppd-fc-field="' + esc(p.field || n.label) + '" data-ppd-fc-set="' + esc(JSON.stringify(fc.settings)) + '"' : '';
            var approx = fc.id && FC_APPROX[fc.cat] && FC_APPROX[fc.cat][fc.id];
            var labelStyle = p.labelStyle || 'default';
            var iconPre = (p.iconPre || p.icon || '');
            var iconPost = (p.iconPost || '');
            var isPlugin = !!fc.id;
            var floatable = !isPlugin && p.mode !== 'view' && ['text', 'email', 'password', 'tel', 'url', 'number', 'currency', 'textarea'].indexOf(t) > -1;

            // Checkbox / toggle keep their inline check line (no float/label wrap)
            if (!isPlugin && p.mode !== 'view' && t === 'checkbox') return '<div class="pp-fld"' + pv + '><label class="pp-checkline"><input type="checkbox" disabled><span>' + esc(n.label) + reqMark + '</span></label>' + help + '</div>';
            if (!isPlugin && p.mode !== 'view' && t === 'toggle') return '<div class="pp-fld"' + pv + '><label class="pp-checkline"><span class="ppd-c-toggle"></span><span>' + esc(n.label) + reqMark + '</span></label>' + help + '</div>';

            var inp, canFloat = floatable;
            var ph = (labelStyle.indexOf('float') === 0 && floatable) ? ' ' : (p.placeholder || '');
            if (approx) { inp = '<div class="ppd-fc-slot">' + approx(n, fc.settings) + '</div>'; canFloat = false; }
            else if (p.mode === 'view') { inp = '<div class="ppd-c-viewval">Sample value</div>'; canFloat = false; }
            else if (t === 'textarea') {
                // Style → Min height must grow the CONTROL, not just the node
                // wrapper (mirrors emitFormField in the build).
                var taMh = (n.style && n.style.minHeight) ? String(n.style.minHeight).trim() : '';
                if (/^\d+(\.\d+)?$/.test(taMh)) taMh += 'px';
                inp = '<textarea class="pp-input" rows="3" disabled placeholder="' + esc(ph) + '"' + (taMh ? ' style="min-height:' + esc(taMh) + '"' : '') + '></textarea>';
            }
            else if (t === 'select') {
                var opts = Array.isArray(p.options) ? p.options : String(p.options || '').split(',').map(function (s) { return s.trim(); }).filter(Boolean);
                inp = '<select class="pp-input" disabled><option>' + esc(opts.length ? opts[0] : 'Choose…') + '</option></select>'; canFloat = false;
            }
            else if (t === 'radio') { inp = '<div class="ppd-c-radios"><label><input type="radio" disabled> Option A</label><label><input type="radio" disabled> Option B</label></div>'; canFloat = false; }
            else if (t === 'file') { inp = '<div class="ppd-c-file"><i class="bi bi-paperclip"></i> Choose file…</div>'; canFloat = false; }
            else if (t === 'date' || t === 'datetime-local' || t === 'time') { inp = '<input class="pp-input" type="text" disabled placeholder="' + esc(p.placeholder || (t === 'time' ? 'Pick a time…' : 'Pick a date…')) + '">'; if (!iconPre) iconPre = t === 'time' ? 'bi-clock' : 'bi-calendar3'; canFloat = false; }
            else inp = '<input class="pp-input" type="' + esc(t) + '" disabled placeholder="' + esc(ph) + '">';

            return fieldShell(pv, esc(n.label), reqMark, inp, help, labelStyle, iconPre, iconPost, canFloat, p.labelWidth || '', p.labelGap || '');
        },
        schema: function () { return [ { title: 'Labels & text', fields: [
            { key: 'label', label: 'Label', type: 'label', section: 'labels', mirror: '@node.label', hint: 'The field label — shared with Misc → Label Editor → Field Labels' },
            { key: 'placeholder', label: 'Placeholder', type: 'label', section: 'placeholders', mirror: 'placeholder', hint: 'Shared with Misc → Label Editor → Placeholders' },
            { key: 'tooltip', label: 'Tooltip', type: 'label', section: 'tooltips', hint: 'Shared with Misc → Label Editor → Tooltips' }
        ] }, { title: 'General', fields: [
            { key: 'field', label: 'DB field', type: 'text' },
            { key: 'mode', label: 'Mode', type: 'select', options: ['edit', 'view'] },
            { key: 'help', label: 'Help text', type: 'text' },
            { key: 'required', label: 'Required', type: 'bool' },
            // Both take a PHP EXPRESSION (text must be quoted) and open the Code
            // Editor modal — same compiler as the build's compileDefaultExpr.
            { key: 'defaultValue', label: 'Default value (add)', type: 'php' },
            { key: 'autoUpdate', label: 'AutoUpdate value (edit)', type: 'php' },
            // Computed columns (e.g. Full Name from first + last) usually want
            // the same expression on insert as well.
            { key: 'autoUpdateOnAdd', label: 'Apply on Add too', type: 'bool', when: 'autoUpdate' },
            // Only choice-style controls consume an options list.
            // Shown for choice inputs and for the edit plugins that render a
            // choice list (Lookup Wizard sources its own rows from a table).
            { key: 'options', label: 'Options (select/radio)', type: 'csv', placeholder: 'Option A, Option B',
              when: function (p) {
                  if (String(p.mode || 'edit') === 'view') return false;   // view mode shows a value
                  var ec = String(p.editControl || '');
                  // A chosen edit plugin decides: only list-style ones use options
                  // (Lookup Wizard sources its rows from a table).
                  if (ec) return ['Dropdown', 'RadioGroup', 'ButtonToggle'].indexOf(ec) > -1;
                  return ['select', 'radio'].indexOf(String(p.inputType || '')) > -1;
              } },
            { key: 'visible', label: 'Visible', type: 'bool', target: 'visibility' }
        ] }, { title: 'Label & input layout', fields: [
            { key: 'labelStyle', label: 'Label style', type: 'select', options: ['default', 'inline', 'float-outline', 'float-filled'] },
            { key: 'labelWidth', label: 'Label width (inline)', type: 'text', placeholder: 'e.g. 120px or 30%' },
            { key: 'labelGap', label: 'Label ↔ input gap', type: 'text', placeholder: 'e.g. 6px' },
            { key: 'iconPre', label: 'Leading icon', type: 'icon' },
            { key: 'iconPost', label: 'Trailing icon', type: 'icon' }
        ] }, { title: 'Field control (plugins)', fields: [
            { key: 'editControl', label: 'Edit control', type: 'control', category: 'edit', settingsKey: 'editSettings' },
            { key: 'viewControl', label: 'View control', type: 'control', category: 'view', settingsKey: 'viewSettings' },
            { key: 'fcApplyAll', label: 'Apply to all pages', type: 'bool' },
            { key: 'inputType', label: 'Fallback input', type: 'select', options: ['text', 'number', 'email', 'password', 'tel', 'url', 'date', 'time', 'datetime-local', 'textarea', 'select', 'checkbox', 'radio', 'toggle', 'file', 'color'] }
        ] }, { title: 'Validation', fields: [
            { key: 'validations', label: 'Rules', type: 'validations' }
        ] }, { title: 'Size & layout (responsive)', fields: [{ key: 'spans', label: 'Width', type: 'spans' }] }, styleGroup() ]; }
    });
    def('form', {
        label: 'Form', icon: 'bi-ui-checks-grid', category: 'Form', container: true,
        defaults: function () { return { fieldSpan: { desktop: 6, tablet: 6, mobile: 12 }, gap: 'normal', labelPosition: 'top', clientValidation: true, warnUnsaved: true, hotkeys: true, autofocusFirst: true, requiredHint: true, stickyActions: false }; },
        render: function (n, ctx) { return ctx.renderChildren(n, 'ppd-form12 gap-' + (n.props.gap || 'normal') + ' lbl-' + (n.props.labelPosition || 'top')); },
        schema: function () { return [ generalGroup([
            { key: 'gap', label: 'Spacing', type: 'select', options: ['tight', 'normal', 'roomy'] },
            { key: 'labelPosition', label: 'Labels', type: 'select', options: ['top', 'left'] }
        ]), { title: 'Behaviour (generated app)', fields: [
            { key: 'clientValidation', label: 'Live validation + summary', type: 'bool' },
            { key: 'warnUnsaved', label: 'Warn on unsaved changes', type: 'bool' },
            { key: 'hotkeys', label: 'Ctrl/Cmd+S saves', type: 'bool' },
            { key: 'autofocusFirst', label: 'Autofocus first field', type: 'bool' },
            { key: 'requiredHint', label: 'Show "* required" hint', type: 'bool' },
            { key: 'stickyActions', label: 'Sticky action bar', type: 'bool' }
        ] }, { title: 'Default field width', fields: [{ key: 'fieldSpan', label: 'Width', type: 'spans' }] }, styleGroup() ]; }
    });
    // Free field placement: a width-spanned SUB-GRID. Drop fields inside to
    // stack them vertically in one column (e.g. two short fields beside one
    // tall textarea) or nest them side-by-side (children keep their own Size &
    // Layout spans inside the group's fresh 12-column grid). Mirrored by
    // ComponentRenderer::case 'field-group'; on generated add/edit pages the
    // form walker recurses the children, so nested fields stay REAL inputs.
    def('field-group', {
        label: 'Field Group', icon: 'bi-distribute-vertical', category: 'Form', container: true,
        _desc: 'A sub-grid that occupies a column and stacks or nests fields inside it — put two short fields beside one tall textarea for balanced forms.',
        defaults: function () { return { gap: 'normal', spans: { desktop: 6, tablet: 12, mobile: 12 } }; },
        render: function (n, ctx) {
            var g = ['tight', 'normal', 'roomy'].indexOf(n.props.gap) !== -1 ? n.props.gap : 'normal';
            return ctx.renderChildren(n, 'ppd-form12 gap-' + g + ' ppd-fieldgroup');
        },
        schema: function () { return [ generalGroup([
            { key: 'gap', label: 'Spacing between fields', type: 'select', options: ['tight', 'normal', 'roomy'] }
        ]), styleGroup() ]; }
    });

    /* ---- Cell Grid (PHPRunner-style rows × columns with merge/split) ------
       Data model: props { rows, cols, gap, colWidths, cells: { "r:c": {rs, cs,
       align, valign, bg, color, pad, border, minH} } }. Children carry
       tab = r*cols + c (their master cell's index) — the SAME numeric-zone
       mechanism Tabs/Card use, so drag-drop into cells needs no new plumbing.
       Cells covered by a merge are computed, never stored. Mirrored by
       ComponentRenderer::case 'layout-grid'. */
    function lgCovered(p) {
        var R = Math.max(1, parseInt(p.rows, 10) || 1), C = Math.max(1, parseInt(p.cols, 10) || 1);
        var cells = p.cells || {}, cov = {};
        for (var r = 0; r < R; r++) for (var c = 0; c < C; c++) {
            if (cov[r + ':' + c]) continue;                    // swallowed → spans ignored
            var cfg = cells[r + ':' + c] || {};
            var rs = Math.max(1, parseInt(cfg.rs, 10) || 1), cs = Math.max(1, parseInt(cfg.cs, 10) || 1);
            for (var i = r; i < Math.min(R, r + rs); i++)
                for (var j = c; j < Math.min(C, c + cs); j++)
                    if (i !== r || j !== c) cov[i + ':' + j] = true;
        }
        return cov;
    }
    function lgTracks(colWidths, C) {
        var toks = String(colWidths || '').split(',').map(function (s) { return s.trim(); });
        var out = [];
        for (var i = 0; i < C; i++) {
            var t = toks[i] || '';
            out.push(/^([0-9.]+(fr|px|%|rem|em)|auto|min-content|max-content)$/.test(t) ? t : 'minmax(0,1fr)');
        }
        return out.join(' ');
    }
    function lgCellCss(cfg) {
        var s = '';
        if (['left', 'center', 'right'].indexOf(cfg.align) > -1) s += 'text-align:' + cfg.align + ';';
        // NOTE: vertical align is applied as a class (lg-v-*) on the cell so it can
        // target the zone's align-content — the cell's own justify-content is a
        // no-op because the zone fills the cell height (flex:1 1 auto).
        if (cfg.bg) s += 'background:' + esc(cfg.bg) + ';';
        if (cfg.color) s += 'color:' + esc(cfg.color) + ';';
        if (cfg.pad) s += 'padding:' + esc(cfg.pad) + ';';
        if (cfg.margin) s += 'margin:' + esc(cfg.margin) + ';';
        if (cfg.border) s += 'border:' + esc(cfg.border) + ';';
        if (cfg.minH) s += 'min-height:' + esc(cfg.minH) + ';';
        if (cfg.h) s += 'height:' + esc(cfg.h) + ';';
        if (cfg.maxH) s += 'max-height:' + esc(cfg.maxH) + ';overflow:auto;';
        return s;
    }
    // Per-cell responsive classes: hide on a device, or force full-width there.
    // Plus horizontal alignment of the component inside the cell (left/center/
    // right → the component keeps its natural width; full/'' = fill, the default).
    function lgCellCls(cfg) {
        var h = { left: ' lg-h-left', center: ' lg-h-center', right: ' lg-h-right' }[cfg.align] || '';
        var v = { top: ' lg-v-top', middle: ' lg-v-middle', bottom: ' lg-v-bottom' }[cfg.valign] || '';
        return (cfg.hideM ? ' lg-hide-m' : '') + (cfg.hideT ? ' lg-hide-t' : '') + (cfg.hideD ? ' lg-hide-d' : '')
            + (cfg.fullT ? ' lg-full-t' : '') + (cfg.fullM ? ' lg-full-m' : '') + h + v;
    }
    // Custom row-track heights (mirror of lgTracks): CSV → grid-template-rows,
    // padded to R with `auto`. Blank = all auto (content height, the default).
    function lgRows(rowHeights, R) {
        var toks = String(rowHeights || '').split(',').map(function (s) { return s.trim(); });
        var out = [];
        for (var i = 0; i < R; i++) {
            var t = toks[i] || '';
            out.push(/^([0-9.]+(fr|px|%|rem|em|vh)|auto|min-content|max-content)$/.test(t) ? t : 'auto');
        }
        return out.join(' ');
    }
    def('layout-grid', {
        label: 'Cell Grid', icon: 'bi-grid-3x2-gap', category: 'Layout', container: true, hot: true,
        _desc: 'A rows × columns cell grid — every cell is selectable and styleable, cells merge/split, rows and columns insert/delete. Drop fields or components into any cell for fully free placement.',
        defaults: function () { return { rows: 2, cols: 2, gap: '', colWidths: '', rowHeights: '', autoFit: false, reflow: 'mobile', rowAlign: 'stretch', cells: {}, spans: { desktop: 12, tablet: 12, mobile: 12 } }; },
        render: function (n, ctx) {
            var p = n.props || {};
            var R = Math.max(1, Math.min(20, parseInt(p.rows, 10) || 2));
            var C = Math.max(1, Math.min(12, parseInt(p.cols, 10) || 2));
            // No gap class → the grid inherits --pp-form-gap from the form it
            // sits in ("Match form"), so the form's Spacing still means something.
            var g = ['tight', 'normal', 'roomy'].indexOf(p.gap) > -1 ? ' gap-' + p.gap : '';
            // Reflow is automatic: cells carry per-device 12-widths (--w-t/--w-m)
            // that the stylesheet + canvas device-preview apply on tablet/mobile.
            var rf = '';
            // Vertical alignment of cells sharing a row. 'stretch' (default) makes
            // side-by-side cards equal height (cells stretch + cards fill); top/
            // center/bottom size cells to content and align them to that edge.
            var ai = ' lg-ai-' + ({ top: 'top', center: 'center', bottom: 'bottom' }[p.rowAlign] || 'stretch');
            // Columns: fluid auto-fit, or explicit tracks (fr / px / % / 12-grid).
            var colT = p.autoFit
                ? 'repeat(auto-fit, minmax(' + (String(p.minColW || '220px').replace(/[^0-9a-z.%]/gi, '') || '220px') + ', 1fr))'
                : lgTracks(p.colWidths, C);
            var rowT = (p.rowHeights && String(p.rowHeights).trim()) ? ';grid-template-rows:' + lgRows(p.rowHeights, R) : '';
            var cov = lgCovered({ rows: R, cols: C, cells: p.cells });
            var out = '<div class="ppd-lgrid' + g + rf + ai + '" style="grid-template-columns:' + colT + rowT + '">';
            for (var r = 0; r < R; r++) for (var c = 0; c < C; c++) {
                if (cov[r + ':' + c]) continue;
                var cfg = (p.cells || {})[r + ':' + c] || {};
                var rs = Math.min(Math.max(1, parseInt(cfg.rs, 10) || 1), R - r);
                var cs = Math.min(Math.max(1, parseInt(cfg.cs, 10) || 1), C - c);
                // 12-column responsive widths (used in reflow = responsive): --w-d
                // desktop, --w-t tablet, --w-m mobile (blank inherits). Desktop
                // defaults to the cell's proportional 12ths so the layout is kept.
                var _w12 = function (v) { return Math.max(1, Math.min(12, parseInt(v, 10) || 12)); };
                var wD = cfg.w ? _w12(cfg.w) : Math.max(1, Math.min(12, Math.round(cs / C * 12)));
                var csv = '--w-d:' + wD + ';--rs:' + rs + ';'
                    + (cfg.wT ? '--w-t:' + _w12(cfg.wT) + ';' : '')
                    + (cfg.wM ? '--w-m:' + _w12(cfg.wM) + ';' : '');
                var st = 'grid-row:' + (r + 1) + ' / span ' + rs + ';grid-column:' + (c + 1) + ' / span ' + cs + ';' + csv + lgCellCss(cfg);
                var inner;
                if (ctx && ctx.renderZone) {
                    inner = ctx.renderZone(n, r * C + c, 'ppd-form12' + g + ' ppd-lgcell-zone', 'Cell ' + (r + 1) + '·' + (c + 1));
                } else if (ctx && ctx.renderChildren) {
                    // Preview / clean render (no drop-zone chrome): emit this cell's
                    // own children, matched by their tab = r*C + c (mirrors the build).
                    var idx = r * C + c;
                    var kids = (n.children || []).filter(function (ch) { return (typeof ch.tab === 'number' ? ch.tab : 0) === idx; });
                    inner = kids.length ? ctx.renderChildren({ children: kids }) : '';
                } else inner = '';
                // Canvas only: a corner "pick" tag so a cell can always be selected,
                // even when a component fills it. (Preview/build have no ctx.renderZone.)
                var pick = (ctx && ctx.renderZone) ? '<span class="ppd-lgcell-pick" title="Select this cell — or Alt-click anywhere in it">' + (r + 1) + '·' + (c + 1) + '</span>' : '';
                out += '<div class="ppd-part ppd-lgcell' + lgCellCls(cfg) + '" data-ppd-id="' + esc(n.id) + '" data-ppd-part="cell:' + r + ':' + c + '" style="' + st + '">' + pick + inner + '</div>';
            }
            return out + '</div>';
        },
        schema: function () { return [ generalGroup([
            // '' = follow the parent form's Spacing (so the form's setting is
            // not dead once a grid holds the fields); the rest override it here.
            { key: 'gap', label: 'Cell spacing', type: 'select', options: [{ value: '', label: 'Match form' }, 'tight', 'normal', 'roomy'] },
            { key: 'colWidths', label: 'Column widths', type: 'text', placeholder: 'e.g. 1fr,2fr or 220px,1fr', hint: 'One value per column (comma-separated): fr for the 12-grid, or px/% for fixed. Blank = equal.' },
            { key: 'rowHeights', label: 'Row heights', type: 'text', placeholder: 'e.g. 120px,auto,1fr', hint: 'One value per row. Blank = each row fits its content.' },
            { key: 'autoFit', label: 'Fluid columns (auto-fit)', type: 'bool', rerender: true, hint: 'Columns wrap responsively, each at least the min width below. Overrides Column widths.' },
            { key: 'minColW', label: 'Min column width', type: 'text', when: function (p) { return !!p.autoFit; }, placeholder: 'e.g. 220px' },
            { key: 'rowAlign', label: 'Cards in a row', type: 'select', options: [['stretch', 'Equal height'], ['top', 'Align top'], ['center', 'Align middle'], ['bottom', 'Align bottom']], hint: 'How cells that share a row line up when their content differs in height. Equal height = every card in the row grows to match the tallest (recommended). Top/Middle/Bottom keep each card its natural height and align them to that edge.' },
            { key: '_reflownote', label: 'On small screens', type: 'note', text: 'Cells reflow automatically. Select any cell and set its <b>Desktop / Tablet / Mobile</b> width (in 12ths) under <b>Size &amp; layout</b> — e.g. two cells at ½&nbsp;·&nbsp;6/12 sit side by side on mobile.' },
            { key: '_gridops', label: 'Structure', type: 'gridops' }
        ]), styleGroup() ]; }
    });
    def('auth_form', {
        label: 'Login Experience', icon: 'bi-shield-lock', category: 'Form',
        defaults: function () { return {
            pageType: 'login', template: 'aurora', brandName: 'Northstar', brandMark: 'N',
            brandTagline: 'Your workspace, beautifully connected.', title: 'Welcome back',
            subtitle: 'Sign in to continue to your workspace.', emailLabel: 'Work email',
            emailPlaceholder: 'you@company.com', passwordLabel: 'Password',
            passwordPlaceholder: 'Enter your password', rememberLabel: 'Keep me signed in',
            forgotLabel: 'Forgot password?', submitLabel: 'Sign in securely',
            signupText: 'New here?', signupLabel: 'Create an account', dividerText: 'or continue with',
            socialProviders: ['google', 'microsoft', 'facebook'], socialPosition: 'after', socialStyle: 'buttons',
            showRemember: true, showForgot: true, showSignup: true, showPasswordToggle: true,
            showSecurityNote: true, securityNote: 'Protected by enterprise-grade security',
            showcaseEyebrow: 'BUILT FOR MOMENTUM', showcaseTitle: 'Everything your team needs, in one calm place.',
            showcaseText: 'Bring projects, people, and progress together without the usual complexity.',
            testimonial: 'Northstar gives our team the clarity to move twice as fast.',
            quoteAuthor: 'Maya Chen · Product Director', accentColor: '#6d5dfc', backgroundColor: '#f4f7fb'
        }; },
        render: function (n) {
            var p = n.props || {};
            var isRegister = p.pageType === 'register';
            var isReset = p.pageType === 'reset_password';
            var isForgot = p.pageType === 'forgot_password';
            var isTwoFactor = p.pageType === 'two_factor';
            var providers = Array.isArray(p.socialProviders) ? p.socialProviders : String(p.socialProviders || '').split(',');
            providers = providers.map(function (v) { return String(v).trim().toLowerCase(); }).filter(Boolean);
            if (isReset || isForgot || isTwoFactor) providers = []; // email/token/code flows — no social entry point
            var names = { google: 'Google', microsoft: 'Microsoft', facebook: 'Facebook', apple: 'Apple', github: 'GitHub', linkedin: 'LinkedIn', okta: 'Okta' };
            var icons = { google: 'bi-google', microsoft: 'bi-microsoft', facebook: 'bi-facebook', apple: 'bi-apple', github: 'bi-github', linkedin: 'bi-linkedin', okta: 'bi-shield-check' };
            var social = providers.length ? '<div class="ppd-auth-social is-' + esc(p.socialStyle || 'buttons') + '">' + providers.map(function (provider) {
                return '<button type="button" title="Continue with ' + esc(names[provider] || title(provider)) + '">' + icon(icons[provider] || 'bi-shield-lock') +
                    '<span>' + esc((p.socialStyle === 'icons' ? '' : 'Continue with ') + (names[provider] || title(provider))) + '</span></button>';
            }).join('') + '</div>' : '';
            var divider = providers.length ? '<div class="ppd-auth-divider"><span>' + esc(p.dividerText || 'or') + '</span></div>' : '';
            var socialBlock = social + divider;
            // Brand comes from the Style designer (Layout → Logo) so it is
            // managed in one place; per-template props are only a fallback.
            var appBrand = (window.PpDesignerBootstrap && window.PpDesignerBootstrap.appBrand) || {};
            var bName = appBrand.name || p.brandName || 'Northstar';
            var bImg = appBrand.image || p.brandImage || '';
            var bMark = bImg
                ? '<img class="ppd-auth-brand-img" src="' + esc(bImg) + '" alt="">'
                : '<span style="background:' + esc(p.accentColor || '#6d5dfc') + '">' + esc((String(bName).trim().charAt(0) || 'A').toUpperCase()) + '</span>';
            var brand = '<div class="ppd-auth-brand">' + bMark + '<div><strong>' + esc(bName) + '</strong><small>' + esc(p.brandTagline || '') + '</small></div></div>';
            var options = (!isRegister && !isReset && !isForgot && !isTwoFactor && (p.showRemember || p.showForgot)) ? '<div class="ppd-auth-options">' +
                (p.showRemember ? '<label><input type="checkbox" disabled checked> ' + esc(p.rememberLabel || 'Remember me') + '</label>' : '<span></span>') +
                (p.showForgot ? '<a href="#">' + esc(p.forgotLabel || 'Forgot password?') + '</a>' : '') + '</div>' : '';
            var eye = p.showPasswordToggle ? '<i class="bi bi-eye ppd-auth-eye"></i>' : '';
            var fieldsHtml;
            if (isRegister) {
                fieldsHtml = '<label><span>' + esc(p.nameLabel || 'Username') + '</span><div><i class="bi bi-person"></i><input disabled placeholder="' + esc(p.namePlaceholder || '') + '"></div></label>' +
                    '<label><span>' + esc(p.emailLabel || 'Email') + '</span><div><i class="bi bi-envelope"></i><input type="email" disabled placeholder="' + esc(p.emailPlaceholder || '') + '"></div></label>' +
                    '<label><span>' + esc(p.passwordLabel || 'Password') + '</span><div><i class="bi bi-lock"></i><input type="password" disabled placeholder="' + esc(p.passwordPlaceholder || '') + '">' + eye + '</div></label>' +
                    '<label><span>' + esc(p.confirmLabel || 'Confirm password') + '</span><div><i class="bi bi-lock-fill"></i><input type="password" disabled placeholder="' + esc(p.confirmPlaceholder || '') + '"></div></label>';
            } else if (isReset) {
                fieldsHtml = '<label><span>' + esc(p.passwordLabel || 'New password') + '</span><div><i class="bi bi-lock"></i><input type="password" disabled placeholder="' + esc(p.passwordPlaceholder || '') + '">' + eye + '</div></label>' +
                    '<label><span>' + esc(p.confirmLabel || 'Confirm new password') + '</span><div><i class="bi bi-lock-fill"></i><input type="password" disabled placeholder="' + esc(p.confirmPlaceholder || '') + '"></div></label>' +
                    (p.showStrength !== false ? '<div class="ppd-auth-strength"><i></i><i></i><i></i><i></i></div>' : '');
            } else if (isForgot) {
                fieldsHtml = '<label><span>' + esc(p.emailLabel || 'Email') + '</span><div><i class="bi bi-envelope"></i><input type="email" disabled placeholder="' + esc(p.emailPlaceholder || '') + '"></div></label>' +
                    (p.helpText ? '<div class="ppd-auth-help">' + esc(p.helpText) + '</div>' : '');
            } else if (isTwoFactor) {
                fieldsHtml = '<label><span>' + esc(p.codeLabel || '6-digit code') + '</span><div><i class="bi bi-shield-lock"></i><input disabled inputmode="numeric" style="letter-spacing:6px;text-align:center" placeholder="' + esc(p.codePlaceholder || '000000') + '"></div></label>';
            } else {
                fieldsHtml = '<label><span>' + esc(p.emailLabel || 'Email') + '</span><div><i class="bi bi-envelope"></i><input type="email" disabled placeholder="' + esc(p.emailPlaceholder || '') + '"></div></label>' +
                    '<label><span>' + esc(p.passwordLabel || 'Password') + '</span><div><i class="bi bi-lock"></i><input type="password" disabled placeholder="' + esc(p.passwordPlaceholder || '') + '">' + eye + '</div></label>';
            }
            var form = '<div class="ppd-auth-fields">' + fieldsHtml + '</div>' + options +
                '<button type="button" class="ppd-auth-submit" style="background:' + esc(p.accentColor || '#6d5dfc') + '">' + esc(p.submitLabel || (isRegister ? 'Create account' : (isReset ? 'Reset password' : (isForgot ? 'Send reset link' : (isTwoFactor ? 'Verify & sign in' : 'Sign in'))))) + '<i class="bi bi-arrow-right"></i></button>';
            var card = '<div class="ppd-auth-card">' + brand + '<div class="ppd-auth-heading"><h2>' + esc(p.title || 'Welcome back') + '</h2><p>' + esc(p.subtitle || '') + '</p></div>' +
                (p.socialPosition === 'before' ? socialBlock : '') + form + (p.socialPosition !== 'before' ? divider + social : '') +
                (p.showSignup ? '<div class="ppd-auth-signup">' + esc(p.signupText || '') + ' <a href="#">' + esc(p.signupLabel || '') + '</a></div>' : '') +
                (p.showSecurityNote ? '<div class="ppd-auth-security"><i class="bi bi-shield-check"></i>' + esc(p.securityNote || '') + '</div>' : '') + '</div>';
            var showcase = '<div class="ppd-auth-showcase">' + brand + '<div class="ppd-auth-copy"><small>' + esc(p.showcaseEyebrow || '') + '</small><h1>' + esc(p.showcaseTitle || '') + '</h1><p>' + esc(p.showcaseText || '') + '</p></div>' +
                (p.testimonial ? '<blockquote>“' + esc(p.testimonial) + '”<footer>' + esc(p.quoteAuthor || '') + '</footer></blockquote>' : '') +
                '<div class="ppd-auth-orbit"><i></i><i></i><i></i></div></div>';
            return '<section class="ppd-auth-experience is-' + esc(p.template || 'aurora') + '" style="--auth-accent:' + esc(p.accentColor || '#6d5dfc') + ';--auth-bg:' + esc(p.backgroundColor || '#f4f7fb') + '">' +
                '<div class="ppd-auth-glow glow-a"></div><div class="ppd-auth-glow glow-b"></div>' + showcase + '<div class="ppd-auth-card-zone">' + card + '</div></section>';
        },
        schema: function (n) {
            var pt = (n && n.props && n.props.pageType) || 'login';
            // The 2FA challenge is a code step — hide the login/register form
            // fields, social sign-in and sign-up prompt; keep only what applies.
            if (pt === 'two_factor') return [
                generalGroup([
                    { key: 'title', label: 'Heading', type: 'text' },
                    { key: 'subtitle', label: 'Subheading', type: 'textarea' },
                    { key: 'submitLabel', label: 'Verify button', type: 'text' }
                ]),
                { title: 'Code field', fields: [
                    { key: 'codeLabel', label: 'Code label', type: 'text' },
                    { key: 'codePlaceholder', label: 'Code placeholder', type: 'text' }
                ] },
                { title: 'Appearance', fields: [
                    { key: 'accentColor', label: 'Accent', type: 'color' },
                    { key: 'backgroundColor', label: 'Background', type: 'color' }
                ] },
                { title: 'Showcase panel', fields: [
                    { key: 'showcaseEyebrow', label: 'Eyebrow', type: 'text' },
                    { key: 'showcaseTitle', label: 'Feature heading', type: 'textarea' },
                    { key: 'showcaseText', label: 'Feature text', type: 'textarea' },
                    { key: 'showSecurityNote', label: 'Show security note', type: 'bool' },
                    { key: 'securityNote', label: 'Security note', type: 'text' }
                ] },
                styleGroup()
            ];
            return [
            generalGroup([
                { key: 'title', label: 'Heading', type: 'text' },
                { key: 'subtitle', label: 'Subheading', type: 'textarea' },
                { key: 'submitLabel', label: 'Sign-in button', type: 'text' }
            ]),
            { title: 'Brand (name & logo come from Style → Layout)', fields: [
                { key: 'brandTagline', label: 'Tagline', type: 'text' },
                { key: 'accentColor', label: 'Accent', type: 'color' },
                { key: 'backgroundColor', label: 'Background', type: 'color' }
            ] },
            { title: 'Form fields', fields: [
                { key: 'nameLabel', label: 'Name label (register)', type: 'text' },
                { key: 'namePlaceholder', label: 'Name placeholder', type: 'text' },
                { key: 'emailLabel', label: 'Email label', type: 'text' },
                { key: 'emailPlaceholder', label: 'Email placeholder', type: 'text' },
                { key: 'passwordLabel', label: 'Password label', type: 'text' },
                { key: 'passwordPlaceholder', label: 'Password placeholder', type: 'text' },
                { key: 'confirmLabel', label: 'Confirm label (register)', type: 'text' },
                { key: 'confirmPlaceholder', label: 'Confirm placeholder', type: 'text' },
                { key: 'rememberLabel', label: 'Remember label', type: 'text' },
                { key: 'forgotLabel', label: 'Forgot link', type: 'text' },
                { key: 'showRemember', label: 'Show remember me', type: 'bool' },
                { key: 'showForgot', label: 'Show forgot link', type: 'bool' },
                { key: 'showPasswordToggle', label: 'Password reveal', type: 'bool' }
            ] },
            { title: 'Social sign-in', fields: [
                { key: 'socialProviders', label: 'Providers', type: 'csv', placeholder: 'google, microsoft, facebook' },
                { key: 'socialPosition', label: 'Position', type: 'select', options: ['before', 'after'] },
                { key: 'socialStyle', label: 'Style', type: 'select', options: ['buttons', 'icons'] },
                { key: 'dividerText', label: 'Divider text', type: 'text' }
            ] },
            { title: 'Supporting content', fields: [
                { key: 'showcaseEyebrow', label: 'Eyebrow', type: 'text' },
                { key: 'showcaseTitle', label: 'Feature heading', type: 'textarea' },
                { key: 'showcaseText', label: 'Feature text', type: 'textarea' },
                { key: 'testimonial', label: 'Testimonial', type: 'textarea' },
                { key: 'quoteAuthor', label: 'Quote author', type: 'text' },
                { key: 'showSignup', label: 'Show sign-up link', type: 'bool' },
                { key: 'signupText', label: 'Sign-up prompt', type: 'text' },
                { key: 'signupLabel', label: 'Sign-up link', type: 'text' },
                { key: 'showSecurityNote', label: 'Show security note', type: 'bool' },
                { key: 'securityNote', label: 'Security note', type: 'text' }
            ] },
            styleGroup()
        ]; }
    });
    // The in-app Admin Area (dynamic permissions). A "smart" page: the working
    // console — permission matrix, user/group CRUD, bulk Assign grid — is emitted
    // automatically by the build. Here the designer edits only its chrome:
    // heading, subtitle, which tabs show, accent and the Exit-button label.
    // hidden:true keeps it out of the drag palette; it lives on the Admin Area
    // common page only.
    def('admin-area', {
        label: 'Admin Area', icon: 'bi-shield-lock', category: 'Security', hidden: true,
        defaults: function () { return {
            heading: 'Admin Area',
            subtitle: 'Manage users, groups and permissions — changes take effect immediately.',
            exitLabel: 'Exit Admin Area', accent: '',
            tabPermissions: true, tabUsers: true, tabAssign: true, tabGroups: true, tabGuide: true
        }; },
        render: function (n) {
            var p = n.props || {};
            var tabs = [];
            if (p.tabPermissions !== false) tabs.push(['bi-grid-3x3-gap', 'Permissions']);
            if (p.tabUsers !== false) tabs.push(['bi-people', 'Users']);
            if (p.tabAssign !== false) tabs.push(['bi-ui-checks-grid', 'Assign']);
            if (p.tabGroups !== false) tabs.push(['bi-collection', 'Groups']);
            if (p.tabGuide !== false) tabs.push(['bi-question-circle', 'Guide']);
            var tabHtml = tabs.map(function (t, i) { return '<span class="ppd-aa-tab' + (i === 0 ? ' is-active' : '') + '">' + icon(t[0]) + esc(t[1]) + '</span>'; }).join('');
            var acc = /^#[0-9a-fA-F]{3,8}$/.test(String(p.accent || '')) ? ' style="--ppd-aa-accent:' + esc(p.accent) + '"' : '';
            var cols = ['View', 'Add', 'Edit', 'Del', 'Exp', 'Imp'];
            var head = '<span class="ppd-aa-th ppd-aa-first">Table</span>' + cols.map(function (c) { return '<span class="ppd-aa-th">' + esc(c) + '</span>'; }).join('');
            var rows = ['customers', 'orders', 'products'].map(function (t) {
                return '<span class="ppd-aa-td ppd-aa-first">' + esc(t) + '</span>' + cols.map(function (c, i) { return '<span class="ppd-aa-cell' + (i < 2 ? ' on' : '') + '"></span>'; }).join('');
            }).join('');
            return '<section class="ppd-aa"' + acc + '>' +
                '<div class="ppd-aa-head"><div><h3>' + esc(p.heading || 'Admin Area') + '</h3><p>' + esc(p.subtitle || '') + '</p></div>' +
                '<span class="ppd-aa-exit">' + icon('bi-box-arrow-left') + esc(p.exitLabel || 'Exit Admin Area') + '</span></div>' +
                '<div class="ppd-aa-tabs">' + (tabHtml || '<span class="ppd-aa-muted">No tabs enabled</span>') + '</div>' +
                '<div class="ppd-aa-matrix">' + head + rows + '</div>' +
                '<div class="ppd-aa-note">' + icon('bi-info-circle') + 'Live preview. The working console — permission matrix, user &amp; group management and the bulk Assign grid — is generated automatically and stays fully functional.</div>' +
                '</section>';
        },
        schema: function () { return [
            generalGroup([
                { key: 'heading', label: 'Heading', type: 'text' },
                { key: 'subtitle', label: 'Subtitle', type: 'textarea' },
                { key: 'exitLabel', label: 'Exit button label', type: 'text' },
                { key: 'accent', label: 'Accent hex (blank = theme)', type: 'text', placeholder: '#4f46e5' }
            ]),
            { title: 'Tabs', fields: [
                { key: 'tabPermissions', label: 'Permissions', type: 'bool' },
                { key: 'tabUsers', label: 'Users', type: 'bool' },
                { key: 'tabAssign', label: 'Assign (bulk)', type: 'bool' },
                { key: 'tabGroups', label: 'Groups', type: 'bool' },
                { key: 'tabGuide', label: 'Guide', type: 'bool' }
            ] },
            styleGroup()
        ]; }
    });
    // ── Calendar smart-component (Common Pages → Calendar) ──
    // The generated app builds a full standalone Calendar / event-management app
    // (events, sharing, RSVPs, reminders, iCal). This one component is its
    // Designer face: the preview mirrors the real page and its props are the
    // customizable chrome/behaviour the build honours via calendarSettings().
    def('calendar-app', {
        label: 'Calendar', icon: 'bi-calendar-event', category: 'Common', hidden: true,
        defaults: function () { return {
            // Header
            heading: 'Calendar',
            subtitle: 'Plan events, schedule meetings and share calendars across your team.',
            menuLabel: 'Calendar',
            headerAlign: 'left',
            accent: '',
            headingFont: '', headingSize: '', headingColor: '',
            baseFont: '',
            // Toolbar
            newEventLabel: 'New event',
            newIcon: 'bi-plus-lg', newBg: '', newColor: '', newFont: '', newSize: '',
            showNewEvent: true,
            defaultView: 'month',
            viewWeek: true, viewDay: true, viewAgenda: true, viewYear: true,
            showToday: true, showNav: true, showIcs: true,
            // Grid
            firstDay: '0',
            weekendShading: false,
            todayColor: '',
            gridLine: '',
            // Sidebar
            showSidebar: true,
            sideCreate: true, sideMiniMonth: true, sideCalendars: true, sideCategories: true, sideUpcoming: true,
            createLabel: 'Create', createIcon: 'bi-plus-lg', createBg: '', createColor: '', createFont: '', createSize: '',
            // Footer
            showFooter: false,
            footerText: '',
            // Notifications
            allowPersonal: true,
            appReminders: true,
            emailReminders: false
        }; },
        // ── clickable regions ──
        // Each region carries the generic .ppd-part hooks (data-ppd-id +
        // data-ppd-part) so the Designer's existing sub-selection machinery
        // (renderer.syncParts + bindSelection) highlights it and focuses its
        // settings — no bespoke selection code. inspector.renderPart() reads the
        // region and shows just that region's group (see PPD.calendarRegions).
        render: function (n) {
            var p = n.props || {};
            var id = esc(n.id);
            var hex = function (v) { return /^#[0-9a-fA-F]{3,8}$/.test(String(v || '')) ? esc(v) : ''; };
            var css = function (v) { return String(v == null ? '' : v).replace(/[<>"';{}]/g, ''); };
            var part = function (region, cls, inner) {
                return '<div class="' + cls + ' ppd-part" data-ppd-id="' + id + '" data-ppd-part="region:' + region + '">' + inner + '</div>';
            };
            var mon = String(p.firstDay) === '1';
            var accent = hex(p.accent), todayC = hex(p.todayColor), gridC = hex(p.gridLine);
            var baseFont = css(p.baseFont);
            var styleVars = (accent ? '--ppd-cal-accent:' + accent + ';' : '') +
                (todayC ? '--ppd-cal-today:' + todayC + ';' : '') +
                (gridC ? '--ppd-cal-line:' + gridC + ';' : '') +
                (baseFont ? 'font-family:' + baseFont + ';' : '');
            var acc = styleVars ? ' style="' + styleVars + '"' : '';
            var hStyle = (function () {
                var s = (hex(p.headingColor) ? 'color:' + hex(p.headingColor) + ';' : '') +
                    (css(p.headingFont) ? 'font-family:' + css(p.headingFont) + ';' : '') +
                    (css(p.headingSize) ? 'font-size:' + css(p.headingSize) + ';' : '');
                return s ? ' style="' + s + '"' : '';
            })();
            // enabled views (Month always on)
            var vmap = { month: true, week: p.viewWeek !== false, day: p.viewDay !== false, agenda: p.viewAgenda !== false, year: p.viewYear !== false };
            var vlist = [['month', 'Month'], ['week', 'Week'], ['day', 'Day'], ['agenda', 'Agenda'], ['year', 'Year']];
            var active = String(p.defaultView || 'month'); if (!vmap[active]) active = 'month';
            var tabs = vlist.filter(function (v) { return vmap[v[0]]; })
                .map(function (v) { return '<span class="ppd-cal-view' + (v[0] === active ? ' is-active' : '') + '">' + esc(v[1]) + '</span>'; }).join('');
            var dows = (mon ? ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'] : ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'])
                .map(function (d) { return '<span class="ppd-cal-dow">' + d + '</span>'; }).join('');
            var weekendIdx = mon ? [5, 6] : [0, 6];
            var shade = p.weekendShading === true;
            var cells = '';
            for (var i = 0; i < 35; i++) {
                var day = i - 1, col = i % 7;
                var inMonth = day >= 1 && day <= 30;
                var evt = inMonth && (day === 6 || day === 14 || day === 22) ? '<em class="ppd-cal-ev"></em>' : '';
                var today = day === 12 ? ' is-today' : '';
                var wknd = (shade && weekendIdx.indexOf(col) > -1) ? ' is-weekend' : '';
                cells += '<span class="ppd-cal-cell' + (inMonth ? '' : ' is-out') + today + wknd + '">' + (inMonth ? '<b>' + day + '</b>' + evt : '') + '</span>';
            }
            var chips = [];
            chips.push('<span class="ppd-cal-chip">' + icon('bi-list-ul') + esc(p.menuLabel || 'Calendar') + '</span>');
            chips.push('<span class="ppd-cal-chip">' + icon('bi-calendar-week') + (mon ? 'Week starts Monday' : 'Week starts Sunday') + '</span>');
            if (p.allowPersonal !== false) chips.push('<span class="ppd-cal-chip">' + icon('bi-person') + 'Personal calendars' + '</span>');
            if (p.appReminders !== false) chips.push('<span class="ppd-cal-chip">' + icon('bi-bell') + 'In-app notifications' + '</span>');
            if (p.emailReminders) chips.push('<span class="ppd-cal-chip">' + icon('bi-envelope') + 'Email reminders' + '</span>');

            // Per-button style (color / background / font / size) → inline style.
            var btnStyle = function (prefix) {
                var bg = hex(p[prefix + 'Bg']), col = hex(p[prefix + 'Color']),
                    f = css(p[prefix + 'Font']), sz = css(p[prefix + 'Size']);
                return (bg ? 'background:' + bg + ';border-color:' + bg + ';' : '') +
                    (col ? 'color:' + col + ';' : '') + (f ? 'font-family:' + f + ';' : '') + (sz ? 'font-size:' + sz + ';' : '');
            };
            // A selectable button part (btn:new / btn:create) — nested inside its
            // region; "deepest .ppd-part wins" so clicking the button focuses it.
            var btnPart = function (key, cls, iconVal, label) {
                var st = btnStyle(key === 'new' ? 'new' : 'create');
                return '<span class="' + cls + ' ppd-part" data-ppd-id="' + id + '" data-ppd-part="btn:' + key + '"' + (st ? ' style="' + st + '"' : '') + '>' +
                    icon(iconVal || 'bi-plus-lg') + esc(label) + '</span>';
            };

            // Header (page heading + subtitle) — full width, like the built app.
            var head = part('header', 'ppd-cal-head' + (p.headerAlign === 'center' ? ' is-center' : ''),
                '<div class="ppd-cal-htext"><h3' + hStyle + '>' + esc(p.heading || 'Calendar') + '</h3><p>' + esc(p.subtitle || '') + '</p></div>');

            // Toolbar — nav + views + the New-event button (as in the built app).
            var nav = (p.showNav !== false ? icon('bi-chevron-left') + '<b>March 2026</b>' + icon('bi-chevron-right') : '<b>March 2026</b>') +
                (p.showToday !== false ? ' <span class="ppd-cal-today">Today</span>' : '') +
                (p.showIcs !== false ? ' <span class="ppd-cal-tool">' + icon('bi-box-arrow-down') + 'Export</span><span class="ppd-cal-tool">' + icon('bi-box-arrow-up') + 'Import</span>' : '');
            var bar = part('toolbar', 'ppd-cal-bar',
                '<span class="ppd-cal-navtitle">' + nav + '</span><div class="ppd-cal-barr"><div class="ppd-cal-views">' + tabs + '</div>' +
                (p.showNewEvent !== false ? btnPart('new', 'ppd-cal-new', p.newIcon, p.newEventLabel || 'New event') : '') + '</div>');

            var grid = part('grid', 'ppd-cal-grid',
                '<div class="ppd-cal-dows">' + dows + '</div><div class="ppd-cal-cells">' + cells + '</div>');

            // Sidebar mock — mirrors the generated app's left panel.
            var side = '';
            if (p.showSidebar !== false) {
                var s = '';
                if (p.sideCreate !== false) s += btnPart('create', 'ppd-cal-side-create', p.createIcon, p.createLabel || 'Create');
                if (p.sideMiniMonth !== false) {
                    var mm = '';
                    for (var mi = 1; mi <= 31; mi++) mm += '<span' + (mi === 12 ? ' class="is-today"' : '') + '>' + mi + '</span>';
                    s += '<div class="ppd-cal-mini"><div class="ppd-cal-mini-h">March 2026</div><div class="ppd-cal-mini-g">' + mm + '</div></div>';
                }
                if (p.sideCalendars !== false) s += '<div class="ppd-cal-sh">Calendars</div><div class="ppd-cal-srow"><span class="ppd-cal-dot"></span>My Calendar</div>';
                if (p.sideCategories !== false) s += '<div class="ppd-cal-sh">Categories</div><div class="ppd-cal-smut">No events yet</div>';
                if (p.sideUpcoming !== false) s += '<div class="ppd-cal-sh">Upcoming</div><div class="ppd-cal-smut">Nothing coming up</div>';
                side = part('sidebar', 'ppd-cal-side', s);
            }
            // No sidebar → single-column shell (otherwise the main calendar lands
            // in the empty 190px track and is clipped).
            var shell = '<div class="ppd-cal-shell' + (side ? '' : ' ppd-cal-noside') + '">' + side + '<div class="ppd-cal-maincol">' + bar + grid + '</div></div>';

            var foot = p.showFooter === true
                ? part('footer', 'ppd-cal-foot', esc(p.footerText || ('© ' + new Date().getFullYear() + ' — All rights reserved.')))
                : '';

            return '<section class="ppd-cal"' + acc + '>' + head + shell + foot +
                '<div class="ppd-cal-chips">' + chips.join('') + '</div>' +
                '<div class="ppd-cal-note">' + icon('bi-info-circle') + 'Click a region (header, toolbar, sidebar, grid, footer) to style it. The working calendar — events, sharing, RSVPs, reminders and iCal — is generated automatically.</div>' +
                '</section>';
        },
        schema: function () { return [generalGroup([])].concat(PPD.calendarRegions.groups()).concat([styleGroup()]); }
    });
    // Region field groups — the single source of truth for BOTH the whole-page
    // inspector (all groups) and the focused per-region panel (one group). The
    // `region` key ties each group to its clickable canvas part.
    PPD.calendarRegions = {
        groups: function () { return [
            { title: 'Header', region: 'header', fields: [
                { key: 'heading', label: 'Heading', type: 'text' },
                { key: 'subtitle', label: 'Subtitle', type: 'textarea' },
                { key: 'menuLabel', label: 'Menu label', type: 'text', hint: 'The link shown in the app navigation.' },
                { key: 'headerAlign', label: 'Alignment', type: 'select', options: [
                    { value: 'left', label: 'Left' }, { value: 'center', label: 'Center' } ] },
                { key: 'accent', label: 'Accent hex (blank = theme)', type: 'text', placeholder: '#6366f1' },
                { key: 'headingFont', label: 'Heading font', type: 'font' },
                { key: 'headingSize', label: 'Heading size', type: 'text', placeholder: 'e.g. 1.8rem, 30px' },
                { key: 'headingColor', label: 'Heading colour', type: 'text', placeholder: '#0f172a' }
            ] },
            { title: 'Toolbar', region: 'toolbar', fields: [
                { key: 'defaultView', label: 'Default view', type: 'select', options: [
                    { value: 'month', label: 'Month' }, { value: 'week', label: 'Week' },
                    { value: 'day', label: 'Day' }, { value: 'agenda', label: 'Agenda' }, { value: 'year', label: 'Year' } ] },
                { key: 'viewWeek', label: 'Show Week view', type: 'bool' },
                { key: 'viewDay', label: 'Show Day view', type: 'bool' },
                { key: 'viewAgenda', label: 'Show Agenda view', type: 'bool' },
                { key: 'viewYear', label: 'Show Year view', type: 'bool' },
                { key: 'showToday', label: 'Show “Today” button', type: 'bool' },
                { key: 'showNav', label: 'Show prev / next arrows', type: 'bool' },
                { key: 'showIcs', label: 'Show Import / Export', type: 'bool' },
                { key: 'showNewEvent', label: 'Show “New event” button', type: 'bool' },
                { key: 'newEventLabel', label: 'New-event label', type: 'text', placeholder: 'New event' }
            ] },
            { title: 'Sidebar', region: 'sidebar', fields: [
                { key: 'showSidebar', label: 'Show side panel', type: 'bool', hint: 'The left panel with the mini-month, calendars, categories and upcoming events.' },
                { key: 'sideCreate', label: 'Show “Create” button', type: 'bool' },
                { key: 'sideMiniMonth', label: 'Show mini-month navigator', type: 'bool' },
                { key: 'sideCalendars', label: 'Show Calendars list', type: 'bool' },
                { key: 'sideCategories', label: 'Show Categories', type: 'bool' },
                { key: 'sideUpcoming', label: 'Show Upcoming events', type: 'bool' }
            ] },
            { title: 'Grid', region: 'grid', fields: [
                { key: 'firstDay', label: 'Week starts on', type: 'select', options: [
                    { value: '0', label: 'Sunday' }, { value: '1', label: 'Monday' } ] },
                { key: 'weekendShading', label: 'Shade weekends', type: 'bool', hint: 'Tint Saturday & Sunday columns.' },
                { key: 'todayColor', label: 'Today colour (blank = accent)', type: 'text', placeholder: '#ef4444' },
                { key: 'gridLine', label: 'Grid line colour (blank = theme)', type: 'text', placeholder: '#e2e8f0' }
            ] },
            { title: 'Footer', region: 'footer', fields: [
                { key: 'showFooter', label: 'Show calendar footer', type: 'bool', hint: 'A caption line under the calendar.' },
                { key: 'footerText', label: 'Footer text', type: 'text', placeholder: '© 2026 — All rights reserved.' }
            ] },
            { title: 'Appearance', region: '', fields: [
                { key: 'baseFont', label: 'Base font', type: 'font', hint: 'Font for the whole calendar app.' }
            ] },
            { title: 'Notifications', region: '', fields: [
                { key: 'allowPersonal', label: 'Personal calendars', type: 'bool', hint: 'Let each user keep their own private events.' },
                { key: 'appReminders', label: 'In-app notifications', type: 'bool', hint: 'Deliver event reminders to the in-app notification bell.' },
                { key: 'emailReminders', label: 'Email reminders', type: 'bool', hint: 'Send event reminders by email (needs SMTP).' }
            ] }
        ]; },
        groupFor: function (region) {
            var gs = this.groups();
            for (var i = 0; i < gs.length; i++) if (gs[i].region === region) return gs[i];
            return null;
        }
    };
    // Per-button customization (click the "New event" / "Create" button on the
    // canvas). Fields write flat prefixed props (newXxx / createXxx) the build
    // reads and passes to PPCalendarApp.mount, so the generated button matches.
    PPD.calendarButtons = {
        'new': { title: '“New event” button', fields: [
            { key: 'newEventLabel', label: 'Label', type: 'text', placeholder: 'New event' },
            { key: 'newIcon', label: 'Icon', type: 'icon' },
            { key: 'newBg', label: 'Background', type: 'text', placeholder: '#2563eb' },
            { key: 'newColor', label: 'Text colour', type: 'text', placeholder: '#ffffff' },
            { key: 'newFont', label: 'Font', type: 'font' },
            { key: 'newSize', label: 'Font size', type: 'text', placeholder: 'e.g. 14px, .95rem' }
        ] },
        'create': { title: '“Create” button', fields: [
            { key: 'createLabel', label: 'Label', type: 'text', placeholder: 'Create' },
            { key: 'createIcon', label: 'Icon', type: 'icon' },
            { key: 'createBg', label: 'Background', type: 'text', placeholder: '#2563eb' },
            { key: 'createColor', label: 'Text colour', type: 'text', placeholder: '#ffffff' },
            { key: 'createFont', label: 'Font', type: 'font' },
            { key: 'createSize', label: 'Font size', type: 'text', placeholder: 'e.g. 14px, .95rem' }
        ] }
    };
    // NOTE: the login-time Two-Factor CHALLENGE page is now a template-gallery
    // common page (like Login/Register) — its look is chosen in Designer → Common
    // Pages → Two-Factor and rendered via the shared `auth_form` component with
    // pageType 'two_factor'. The in-app 2FA *setup* page (toggle + method cards)
    // is a built-in generated page and is not designed here.
    def('account_profile', {
        label: 'Account Profile', icon: 'bi-person-badge', category: 'Form',
        defaults: function () { return {
            pageType: 'profile', template: 'card', title: 'My profile',
            subtitle: 'Your account details and preferences.',
            accentColor: '#2563eb', coverColor: '#0f172a',
            showAvatar: true, avatarShape: 'circle',
            showUsername: true, showEmail: true, showRole: true, showJoined: true,
            nameLabel: 'Full name', namePlaceholder: 'Your display name', saveLabel: 'Save changes',
            showPasswordLink: true, passwordLinkLabel: 'Change password',
            showLogoutLink: false, logoutLinkLabel: 'Sign out'
        }; },
        render: function (n) {
            var p = n.props || {};
            var tpl = ['cover', 'split', 'verdant'].indexOf(p.template) > -1 ? p.template : 'card';
            var accent = esc(p.accentColor || '#2563eb');
            var avatar = p.showAvatar ? '<span class="ppd-prof-avatar is-' + esc(p.avatarShape || 'circle') + '" style="background:' + accent + '">AD</span>' : '';
            var role = p.showRole ? '<span class="ppd-prof-role">Administrator</span>' : '';
            var rows = '';
            if (p.showUsername) rows += '<div class="ppd-prof-row"><span>Username</span><strong>admin</strong></div>';
            if (p.showEmail) rows += '<div class="ppd-prof-row"><span>Email</span><strong>admin@company.com</strong></div>';
            if (p.showRole) rows += '<div class="ppd-prof-row"><span>Role</span><strong>Administrator</strong></div>';
            if (p.showJoined) rows += '<div class="ppd-prof-row"><span>Member since</span><strong>Jan 2026</strong></div>';
            var links = '';
            if (p.showPasswordLink) links += '<a href="#" class="ppd-prof-link"><i class="bi bi-key"></i>' + esc(p.passwordLinkLabel || 'Change password') + '</a>';
            if (p.showLogoutLink) links += '<a href="#" class="ppd-prof-link is-muted"><i class="bi bi-box-arrow-right"></i>' + esc(p.logoutLinkLabel || 'Sign out') + '</a>';
            var form = '<label class="ppd-prof-field"><span>' + esc(p.nameLabel || 'Full name') + '</span><input disabled placeholder="' + esc(p.namePlaceholder || '') + '"></label>' +
                '<button type="button" class="ppd-prof-save" style="background:' + accent + '">' + esc(p.saveLabel || 'Save changes') + '</button>';
            var head = '<div class="ppd-prof-head"><h2>' + esc(p.title || 'My profile') + '</h2><p>' + esc(p.subtitle || '') + '</p></div>';
            if (tpl === 'cover') {
                return '<section class="ppd-profile is-cover" style="--prof-accent:' + accent + '">' +
                    '<div class="ppd-prof-cover" style="background:linear-gradient(120deg,' + esc(p.coverColor || '#0f172a') + ',' + accent + ')"></div>' +
                    '<div class="ppd-prof-cover-body">' + avatar + '<div class="ppd-prof-id"><strong>Administrator</strong>' + role + '</div>' + head +
                    '<div class="ppd-prof-grid">' + rows + '</div><div class="ppd-prof-form">' + form + '</div><div class="ppd-prof-links">' + links + '</div></div></section>';
            }
            if (tpl === 'split') {
                return '<section class="ppd-profile is-split" style="--prof-accent:' + accent + '">' +
                    '<aside class="ppd-prof-side">' + avatar + '<strong>Administrator</strong>' + role + '<div class="ppd-prof-links">' + links + '</div></aside>' +
                    '<div class="ppd-prof-main">' + head + '<div class="ppd-prof-grid">' + rows + '</div><div class="ppd-prof-form">' + form + '</div></div></section>';
            }
            if (tpl === 'verdant') {
                return '<section class="ppd-profile is-verdant" style="--prof-accent:' + accent + '">' +
                    '<div class="ppd-prof-card">' + avatar + '<div class="ppd-prof-id"><strong>Administrator</strong>' + role + '</div>' + head +
                    '<div class="ppd-prof-grid">' + rows + '</div><div class="ppd-prof-form">' + form + '</div><div class="ppd-prof-links">' + links + '</div></div></section>';
            }
            return '<section class="ppd-profile is-card" style="--prof-accent:' + accent + '">' +
                '<div class="ppd-prof-card">' + avatar + '<div class="ppd-prof-id"><strong>Administrator</strong>' + role + '</div>' + head +
                '<div class="ppd-prof-grid">' + rows + '</div><div class="ppd-prof-form">' + form + '</div><div class="ppd-prof-links">' + links + '</div></div></section>';
        },
        schema: function () { return [
            generalGroup([
                { key: 'title', label: 'Heading', type: 'text' },
                { key: 'subtitle', label: 'Subheading', type: 'textarea' },
                { key: 'template', label: 'Layout', type: 'select', options: ['card', 'cover', 'split', 'verdant'] }
            ]),
            { title: 'Identity', fields: [
                { key: 'showAvatar', label: 'Show avatar', type: 'bool' },
                { key: 'avatarShape', label: 'Avatar shape', type: 'select', options: ['circle', 'rounded'] },
                { key: 'showUsername', label: 'Show username', type: 'bool' },
                { key: 'showEmail', label: 'Show email', type: 'bool' },
                { key: 'showRole', label: 'Show role', type: 'bool' },
                { key: 'showJoined', label: 'Show member since', type: 'bool' }
            ] },
            { title: 'Editing', fields: [
                { key: 'nameLabel', label: 'Name label', type: 'text' },
                { key: 'namePlaceholder', label: 'Name placeholder', type: 'text' },
                { key: 'saveLabel', label: 'Save button', type: 'text' },
                { key: 'showPasswordLink', label: 'Change-password link', type: 'bool' },
                { key: 'passwordLinkLabel', label: 'Password link label', type: 'text' },
                { key: 'showLogoutLink', label: 'Sign-out link', type: 'bool' },
                { key: 'logoutLinkLabel', label: 'Sign-out label', type: 'text' }
            ] },
            { title: 'Colors', fields: [
                { key: 'accentColor', label: 'Accent', type: 'color' },
                { key: 'coverColor', label: 'Cover (cover layout)', type: 'color' }
            ] },
            styleGroup()
        ]; }
    });
    def('password_form', {
        label: 'Password Form', icon: 'bi-shield-lock', category: 'Form',
        defaults: function () { return {
            pageType: 'change_password', template: 'guard', title: 'Change password',
            subtitle: 'Choose a strong password you have not used before.',
            accentColor: '#2563eb',
            currentLabel: 'Current password', newLabel: 'New password', confirmLabel: 'Confirm new password',
            submitLabel: 'Update password', backLabel: 'Back to profile',
            minLength: 8, showStrength: true, showRequirements: true,
            requireUpper: true, requireNumber: true, requireSymbol: false,
            tipsTitle: 'Good passwords are…',
            tipsText: 'Long over complex — a short phrase beats a cryptic word. Never reuse the password from another site.'
        }; },
        render: function (n) {
            var p = n.props || {};
            var tpl = ['coach', 'compact', 'verdant'].indexOf(p.template) > -1 ? p.template : 'guard';
            var accent = esc(p.accentColor || '#2563eb');
            var field = function (label, icoCls, withEye) {
                return '<label class="ppd-pwd-field"><span>' + esc(label) + '</span><div><i class="bi ' + icoCls + '"></i><input type="password" disabled>' + (withEye ? '<i class="bi bi-eye ppd-auth-eye"></i>' : '') + '</div></label>';
            };
            var reqs = '';
            if (p.showRequirements) {
                var items = ['At least ' + (parseInt(p.minLength, 10) || 8) + ' characters'];
                if (p.requireUpper) items.push('One uppercase letter');
                if (p.requireNumber) items.push('One number');
                if (p.requireSymbol) items.push('One symbol');
                reqs = '<ul class="ppd-pwd-reqs">' + items.map(function (t, i) {
                    return '<li class="' + (i === 0 ? 'ok' : '') + '"><i class="bi ' + (i === 0 ? 'bi-check-circle-fill' : 'bi-circle') + '"></i>' + esc(t) + '</li>';
                }).join('') + '</ul>';
            }
            var strength = p.showStrength ? '<div class="ppd-pwd-strength"><i class="on"></i><i class="on"></i><i></i><i></i><small>Fair</small></div>' : '';
            var form = field(p.currentLabel || 'Current password', 'bi-lock') +
                field(p.newLabel || 'New password', 'bi-shield-lock', true) + strength + reqs +
                field(p.confirmLabel || 'Confirm new password', 'bi-check2-circle') +
                '<button type="button" class="ppd-pwd-save" style="background:' + accent + '">' + esc(p.submitLabel || 'Update password') + '</button>' +
                '<a href="#" class="ppd-pwd-back">' + esc(p.backLabel || 'Back to profile') + '</a>';
            var head = '<div class="ppd-pwd-head"><h2>' + esc(p.title || 'Change password') + '</h2><p>' + esc(p.subtitle || '') + '</p></div>';
            var shield = '<span class="ppd-pwd-shield" style="background:color-mix(in srgb,' + accent + ' 12%,#fff);color:' + accent + '"><i class="bi bi-shield-lock"></i></span>';
            if (tpl === 'coach') {
                return '<section class="ppd-pwdform is-coach" style="--pwd-accent:' + accent + '">' +
                    '<aside class="ppd-pwd-tips">' + shield + '<strong>' + esc(p.tipsTitle || '') + '</strong><p>' + esc(p.tipsText || '') + '</p></aside>' +
                    '<div class="ppd-pwd-main">' + head + form + '</div></section>';
            }
            if (tpl === 'compact') {
                return '<section class="ppd-pwdform is-compact" style="--pwd-accent:' + accent + '"><div class="ppd-pwd-card">' + head + form + '</div></section>';
            }
            if (tpl === 'verdant') {
                return '<section class="ppd-pwdform is-verdant" style="--pwd-accent:' + accent + '"><div class="ppd-pwd-card">' + shield + head + form + '</div></section>';
            }
            return '<section class="ppd-pwdform is-guard" style="--pwd-accent:' + accent + '"><div class="ppd-pwd-card">' + shield + head + form + '</div></section>';
        },
        schema: function () { return [
            generalGroup([
                { key: 'title', label: 'Heading', type: 'text' },
                { key: 'subtitle', label: 'Subheading', type: 'textarea' },
                { key: 'template', label: 'Layout', type: 'select', options: ['guard', 'coach', 'compact', 'verdant'] },
                { key: 'accentColor', label: 'Accent', type: 'color' }
            ]),
            { title: 'Fields', fields: [
                { key: 'currentLabel', label: 'Current password label', type: 'text' },
                { key: 'newLabel', label: 'New password label', type: 'text' },
                { key: 'confirmLabel', label: 'Confirm label', type: 'text' },
                { key: 'submitLabel', label: 'Submit button', type: 'text' },
                { key: 'backLabel', label: 'Back link', type: 'text' }
            ] },
            { title: 'Password policy', fields: [
                { key: 'minLength', label: 'Minimum length', type: 'number' },
                { key: 'requireUpper', label: 'Require uppercase', type: 'bool' },
                { key: 'requireNumber', label: 'Require number', type: 'bool' },
                { key: 'requireSymbol', label: 'Require symbol', type: 'bool' },
                { key: 'showStrength', label: 'Strength meter', type: 'bool' },
                { key: 'showRequirements', label: 'Requirements checklist', type: 'bool' }
            ] },
            { title: 'Coach panel', fields: [
                { key: 'tipsTitle', label: 'Tips heading', type: 'text' },
                { key: 'tipsText', label: 'Tips text', type: 'textarea' }
            ] },
            styleGroup()
        ]; }
    });
    def('buttons', {
        label: 'Buttons', icon: 'bi-ui-checks', category: 'Form',
        defaults: function () { return { buttons: ['save', 'cancel'] }; },
        render: function (n) {
            var cfg = n.props.buttonConfig || {};
            return '<div class="ppd-c-buttons">' + (n.props.buttons || []).map(function (k) {
                var m = resolve('formbtn', k, cfg);
                if (m.visible === false && !isDesign()) return '';
                return partBtn(n, 'button:' + k, m, stateCls(m).trim());
            }).join('') + '</div>';
        },
        schema: function () { return [ generalGroup([{ key: 'buttons', label: 'Buttons', type: 'csv' }]), styleGroup() ]; }
    });
    def('upload', {
        label: 'Upload', icon: 'bi-cloud-upload', category: 'Form',
        defaults: function () { return { accept: '.csv,.xlsx' }; },
        render: function (n) { return '<div class="ppd-upload">' + icon('bi-cloud-arrow-up') + '<div>Drag & drop or browse</div><small>' + esc(n.props.accept || '') + '</small></div>'; },
        schema: function () { return [ generalGroup([{ key: 'accept', label: 'Accept', type: 'text' }]), styleGroup() ]; }
    });
    def('field-selector', {
        label: 'Field Selector', icon: 'bi-list-check', category: 'Form',
        defaults: function () { return { fields: [] }; },
        render: function (n, ctx) {
            var fs = (n.props.fields && n.props.fields.length) ? n.props.fields : ((ctx && ctx.fields) || []).map(function (f) { return f.name; });
            return '<div class="ppd-fieldsel"><label class="ppd-fieldsel-all"><input type="checkbox" checked disabled> <b>Select all</b> <em>' + fs.length + ' fields</em></label>' +
                '<div class="ppd-fieldsel-grid">' + fs.map(function (f) { return '<label class="ppd-fieldsel-item"><input type="checkbox" checked disabled> ' + esc(title(f)) + '</label>'; }).join('') + '</div></div>';
        },
        schema: function () { return [ generalGroup([{ key: 'fields', label: 'Fields', type: 'csv' }]), styleGroup() ]; }
    });

    /* ---------------- more content ---------------- */
    def('divider', {
        label: 'Divider', icon: 'bi-dash-lg', category: 'Content',
        defaults: function () { return { label: '' }; },
        render: function (n) { return n.props.label ? '<div class="ppd-c-divider"><span>' + esc(n.props.label) + '</span></div>' : '<hr class="ppd-c-hr">'; },
        schema: function () { return [ generalGroup([{ key: 'label', label: 'Label', type: 'text' }]), styleGroup() ]; }
    });
    def('alert', {
        label: 'Alert', icon: 'bi-exclamation-triangle', category: 'Content',
        defaults: function () { return { text: 'This is an informational message.', tone: 'info', variant: 'soft', size: 'md', showIcon: true }; },
        render: function (n) {
            var p = n.props || {}, tone = p.tone || 'info';
            var cls = 'ppd-c-alert ' + tone + (p.variant === 'solid' ? ' alert-solid' : p.variant === 'outline' ? ' alert-outline' : p.variant === 'accent' ? ' alert-accent' : '')
                + (p.size === 'sm' ? ' alert-sm' : p.size === 'lg' ? ' alert-lg' : '');
            var cs = boxStyle(p);
            var autoIco = ({ info: 'bi-info-circle', success: 'bi-check-circle', warning: 'bi-exclamation-triangle', danger: 'bi-x-octagon' })[tone] || 'bi-info-circle';
            var ico = (p.showIcon !== false) ? '<span' + elAttrs(n, 'icon', 'ppd-elicon') + '>' + icon(p.icon || autoIco) + '</span>' : '';
            var title = p.title ? '<div class="ppd-c-alert-title">' + esc(p.title) + '</div>' : '';
            var action = (p.actionText && p.actionUrl) ? '<a class="ppd-c-alert-action" href="' + esc(p.actionUrl) + '">' + esc(p.actionText) + '</a>' : '';
            var close = p.dismissible ? '<button type="button" class="ppd-c-alert-close">&times;</button>' : '';
            return '<div class="' + cls + '"' + (cs ? ' style="' + esc(cs) + '"' : '') + '>' + ico + '<div class="ppd-c-alert-body">' + title + '<span' + elAttrs(n, 'text') + '>' + esc(p.text || '') + '</span>' + action + '</div>' + close + '</div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'title', label: 'Title', type: 'text', placeholder: 'Optional bold title' },
            { key: 'text', label: 'Text', type: 'textarea' },
            { key: 'tone', label: 'Tone', type: 'select', options: [['info', 'Info'], ['success', 'Success'], ['warning', 'Warning'], ['danger', 'Danger']] }
        ]),
        { title: 'Style', fields: [
            { key: 'variant', label: 'Variant', type: 'select', options: [['soft', 'Soft'], ['solid', 'Solid'], ['outline', 'Outline'], ['accent', 'Left accent']] },
            { key: 'size', label: 'Size', type: 'select', options: [['sm', 'Small'], ['md', 'Medium'], ['lg', 'Large']] },
            { key: 'showIcon', label: 'Show icon', type: 'bool' },
            { key: 'icon', label: 'Icon', type: 'icon', hint: 'Blank = auto by tone.' },
            { key: 'dismissible', label: 'Dismissible (×)', type: 'bool' }
        ]},
        customStyleGroup(),
        { title: 'Action link', fields: [
            { key: 'actionText', label: 'Link text', type: 'text', placeholder: 'e.g. Learn more' },
            { key: 'actionUrl', label: 'Link URL', type: 'text', placeholder: 'https://…' }
        ]},
        styleGroup() ]; }
    });
    // ── Image styling helpers (shared shape with ComponentRenderer::imageBoxCss/
    //    imageImgCss so the canvas and the built page look identical). ──
    var IMG_SHADOW = { sm: '0 2px 10px rgba(2,6,23,.10)', md: '0 10px 26px rgba(2,6,23,.14)', lg: '0 20px 46px rgba(2,6,23,.22)' };
    function imgClampInt(v, lo, hi, dflt) { var x = parseInt(v, 10); if (isNaN(x)) return dflt; return Math.max(lo, Math.min(hi, x)); }
    function imgBoxCss(p) {
        var w = imgClampInt(p.width, 5, 100, 100);
        var s = 'width:' + w + '%;max-width:100%;';
        var al = p.align || 'center';
        s += al === 'left' ? 'margin-right:auto;margin-left:0;' : al === 'right' ? 'margin-left:auto;margin-right:0;' : 'margin-left:auto;margin-right:auto;';
        if ((p.bg || '').trim()) s += 'background:' + esc(p.bg) + ';';         // blank = transparent
        var pad = imgClampInt(p.pad, 0, 120, 0); if (pad) s += 'padding:' + pad + 'px;';
        var rad = imgClampInt(p.radius, 0, 400, 0); if (rad) s += 'border-radius:' + rad + 'px;';
        if (p.border) s += 'border:' + imgClampInt(p.borderWidth, 1, 40, 1) + 'px solid ' + ((p.borderColor || '').trim() ? esc(p.borderColor) : 'var(--ppd-border,#e2e8f0)') + ';';
        var sh = IMG_SHADOW[p.shadow]; if (sh) s += 'box-shadow:' + sh + ';';
        return s;
    }
    function imgImgCss(p) {
        var s = '';
        var r = (p.ratio || 'auto'); if (r && r !== 'auto') s += 'aspect-ratio:' + esc(r) + ';height:auto;';
        var fit = ['cover', 'contain', 'fill', 'scale-down', 'none'].indexOf(p.fit) > -1 ? p.fit : 'contain';
        s += 'object-fit:' + fit + ';';
        var pos = p.position || 'center'; if (pos !== 'center') s += 'object-position:' + esc(pos) + ';';
        var op = imgClampInt(p.opacity, 0, 100, 100); if (op < 100) s += 'opacity:' + (op / 100) + ';';
        var filt = ''; if (p.grayscale) filt += 'grayscale(1) '; var bl = imgClampInt(p.blur, 0, 30, 0); if (bl) filt += 'blur(' + bl + 'px) ';
        if (filt) s += 'filter:' + filt.trim() + ';';
        return s;
    }
    function imgHoverCls(p) { return ({ zoom: ' ppd-c-imh-zoom', lift: ' ppd-c-imh-lift', glow: ' ppd-c-imh-glow', tilt: ' ppd-c-imh-tilt' }[p.hover] || ''); }
    // ── Marketing / landing "Sections" ──────────────────────────────────────
    // Navbar: a public-site header (logo + links + CTA + mobile burger). Brand
    // defaults inherit the Style module's logo (single source of truth). Mobile
    // menu is a pure-CSS checkbox toggle — no JS, identical on canvas + build.
    function navBrand(p) {
        var B = (window.PpDesignerBootstrap || {}).brand || {};
        return { text: (p.brand != null && String(p.brand).trim() !== '') ? String(p.brand) : (B.name || 'Brand'),
                 image: (p.logo != null && String(p.logo).trim() !== '') ? String(p.logo) : (B.image || '') };
    }
    def('navbar', {
        label: 'Navbar', icon: 'bi-menu-button-wide', category: 'Sections', hot: true,
        _desc: 'A responsive site header: logo (inherits your Style branding), navigation links, a call-to-action button and an automatic mobile hamburger menu. Best on a Standalone / Landing page.',
        defaults: function () { return { brand: '', logo: '', showBrand: true, home: '/',
            links: [{ label: 'Home', href: '#' }, { label: 'Features', href: '#features' }, { label: 'Pricing', href: '#pricing' }, { label: 'Contact', href: '#contact' }],
            ctaShow: true, ctaLabel: 'Get started', ctaHref: '#', ctaStyle: 'solid',
            sticky: true, variant: 'solid', menuAlign: 'right', width: 'contained' }; },
        render: function (n) {
            var p = n.props || {}, b = navBrand(p), id = 'ppnav-' + (n.id || 'x');
            var cls = 'ppd-c-nav ppd-c-nav--' + (['solid', 'transparent', 'blur'].indexOf(p.variant) > -1 ? p.variant : 'solid')
                + (p.sticky !== false ? ' is-sticky' : '') + ' menu-' + (['left', 'center', 'right'].indexOf(p.menuAlign) > -1 ? p.menuAlign : 'right');
            var brand = '<a class="ppd-c-nav-brand">' + (b.image ? '<img src="' + esc(b.image) + '" alt="">' : '')
                + (p.showBrand !== false && b.text ? '<span>' + esc(b.text) + '</span>' : '') + '</a>';
            var links = (Array.isArray(p.links) ? p.links : []).map(function (l) { return '<a class="ppd-c-nav-link">' + esc(l.label || 'Link') + '</a>'; }).join('');
            var cta = (p.ctaShow !== false && (p.ctaLabel || '').trim()) ? '<a class="ppd-c-nav-cta' + (p.ctaStyle === 'outline' ? ' is-outline' : '') + '">' + esc(p.ctaLabel) + '</a>' : '';
            return '<header class="' + cls + '">'
                + '<input type="checkbox" class="ppd-c-nav-toggle" id="' + esc(id) + '">'
                + '<div class="ppd-c-nav-inner' + (p.width === 'full' ? ' is-full' : '') + '">'
                + brand
                + '<label class="ppd-c-nav-burger" for="' + esc(id) + '" aria-label="Menu"><span></span><span></span><span></span></label>'
                + '<nav class="ppd-c-nav-menu">' + links + cta + '</nav>'
                + '</div></header>';
        },
        schema: function () { return [ generalGroup([
            { key: 'brand', label: 'Brand text', type: 'text', placeholder: 'Inherit from Style (Logo)', hint: 'Leave blank to reuse the logo text set in the Style module.' },
            { key: 'logo', label: 'Logo image URL', type: 'text', placeholder: 'Inherit from Style', hint: 'Blank = the Style module’s logo image. A transparent PNG/SVG works best.' },
            { key: 'showBrand', label: 'Show brand text', type: 'bool' },
            { key: 'home', label: 'Brand links to', type: 'text', placeholder: '/', hint: 'Where clicking the logo goes.' }
        ]),
        { title: 'Links', fields: [
            { key: 'links', label: 'Menu links', type: 'list', itemLabel: 'Link', addLabel: 'Add link', default: { label: 'New link', href: '#' }, item: [
                { key: 'label', label: 'Label', type: 'text' },
                { key: 'href', label: 'URL / anchor', type: 'text', placeholder: 'e.g. #pricing or /about' }
            ] },
            { key: 'menuAlign', label: 'Links position', type: 'select', options: [['right', 'Right'], ['center', 'Center'], ['left', 'Left (next to logo)']] }
        ]},
        { title: 'Call to action', fields: [
            { key: 'ctaShow', label: 'Show CTA button', type: 'bool', rerender: true },
            { key: 'ctaLabel', label: 'CTA label', type: 'text', when: function (p) { return p.ctaShow !== false; } },
            { key: 'ctaHref', label: 'CTA link', type: 'text', when: function (p) { return p.ctaShow !== false; }, placeholder: 'e.g. /signup' },
            { key: 'ctaStyle', label: 'CTA style', type: 'select', options: [['solid', 'Solid'], ['outline', 'Outline']], when: function (p) { return p.ctaShow !== false; } }
        ]},
        { title: 'Appearance', fields: [
            { key: 'variant', label: 'Background', type: 'select', options: [['solid', 'Solid'], ['transparent', 'Transparent'], ['blur', 'Glass / blur']], hint: 'Transparent/Glass work well over a Hero.' },
            { key: 'sticky', label: 'Stick to top on scroll', type: 'bool' },
            { key: 'width', label: 'Content width', type: 'select', options: [['contained', 'Contained'], ['full', 'Full width']] }
        ]},
        styleGroup() ]; }
    });
    // Hero: the landing-page centrepiece — headline + subtext + CTAs, optional
    // background (solid/gradient/image) and an optional side image (split layout).
    function heroBgCss(p) {
        var t = p.bgType || 'none', s = '';
        if (t === 'solid' && (p.bg1 || '').trim()) s += 'background:' + esc(p.bg1) + ';';
        else if (t === 'gradient' && (p.bg1 || '').trim()) s += 'background:linear-gradient(' + (parseInt(p.bgAngle, 10) || 135) + 'deg,' + esc(p.bg1) + ',' + esc((p.bg2 || '').trim() || p.bg1) + ');';
        else if (t === 'image' && (p.bgImage || '').trim()) s += 'background-image:url(' + esc(p.bgImage) + ');background-size:cover;background-position:center;';
        return s;
    }
    function heroToneCls(p) {
        if (p.tone === 'light') return ' tone-light';
        if (p.tone === 'dark') return ' tone-dark';
        return (p.bgType === 'image' || p.bgType === 'gradient') ? ' tone-light' : '';
    }
    def('hero', {
        label: 'Hero', icon: 'bi-easel2', category: 'Sections', hot: true,
        _desc: 'The landing-page centrepiece: eyebrow + headline + subtext + call-to-action buttons, over a solid/gradient/image background, centered or as a split with a side image.',
        defaults: function () { return { layout: 'center', align: 'center', eyebrow: 'Introducing',
            headline: 'Build something people love', subhead: 'A clear, benefit-led sentence that tells visitors what you do and why it matters.',
            primaryLabel: 'Get started', primaryHref: '#', secondaryShow: true, secondaryLabel: 'Learn more', secondaryHref: '#',
            image: '', bgType: 'none', bg1: '', bg2: '', bgAngle: 135, bgImage: '', overlay: 45, tone: 'auto', minH: 'lg' }; },
        render: function (n) {
            var p = n.props || {};
            var cls = 'ppd-c-hero layout-' + (p.layout === 'split' ? 'split' : 'center')
                + ' h-' + (['md', 'lg', 'screen'].indexOf(p.minH) > -1 ? p.minH : 'lg') + heroToneCls(p) + (p.bgType === 'image' ? ' has-img' : '');
            var over = (p.bgType === 'image' && (parseInt(p.overlay, 10) || 0) > 0) ? '<div class="ppd-c-hero-ov" style="opacity:' + ((parseInt(p.overlay, 10) || 0) / 100) + '"></div>' : '';
            var eyebrow = (p.eyebrow || '').trim() ? '<span class="ppd-c-hero-eyebrow">' + esc(p.eyebrow) + '</span>' : '';
            var cta = '<div class="ppd-c-hero-cta">'
                + ((p.primaryLabel || '').trim() ? '<a class="ppd-c-hero-btn is-primary">' + esc(p.primaryLabel) + '</a>' : '')
                + (p.secondaryShow !== false && (p.secondaryLabel || '').trim() ? '<a class="ppd-c-hero-btn is-ghost">' + esc(p.secondaryLabel) + '</a>' : '') + '</div>';
            var text = '<div class="ppd-c-hero-text align-' + (p.align === 'left' ? 'left' : 'center') + '">' + eyebrow
                + '<h1 class="ppd-c-hero-h">' + esc(p.headline || 'Headline') + '</h1>'
                + ((p.subhead || '').trim() ? '<p class="ppd-c-hero-sub">' + esc(p.subhead) + '</p>' : '') + cta + '</div>';
            var media = p.layout === 'split' ? '<div class="ppd-c-hero-media">' + (p.image ? '<img src="' + esc(p.image) + '" alt="">' : '<div class="ppd-c-hero-ph">' + icon('bi-image') + '</div>') + '</div>' : '';
            return '<section class="' + cls + '" style="' + heroBgCss(p) + '">' + over + '<div class="ppd-c-hero-inner">' + text + media + '</div></section>';
        },
        schema: function () { return [ generalGroup([
            { key: 'layout', label: 'Layout', type: 'select', options: [['center', 'Centered'], ['split', 'Split (text + image)']], rerender: true },
            { key: 'eyebrow', label: 'Eyebrow', type: 'text', placeholder: 'Small label above the headline (optional)' },
            { key: 'headline', label: 'Headline', type: 'textarea' },
            { key: 'subhead', label: 'Subtext', type: 'textarea' },
            { key: 'align', label: 'Text align', type: 'select', options: [['center', 'Center'], ['left', 'Left']], when: function (p) { return (p.layout || 'center') === 'center'; } },
            { key: 'image', label: 'Side image URL', type: 'text', when: function (p) { return p.layout === 'split'; }, placeholder: 'https://…' },
            { key: 'minH', label: 'Height', type: 'select', options: [['md', 'Compact'], ['lg', 'Tall'], ['screen', 'Full screen']] }
        ]),
        { title: 'Buttons', fields: [
            { key: 'primaryLabel', label: 'Primary label', type: 'text' },
            { key: 'primaryHref', label: 'Primary link', type: 'text', placeholder: '#' },
            { key: 'secondaryShow', label: 'Show secondary button', type: 'bool', rerender: true },
            { key: 'secondaryLabel', label: 'Secondary label', type: 'text', when: function (p) { return p.secondaryShow !== false; } },
            { key: 'secondaryHref', label: 'Secondary link', type: 'text', when: function (p) { return p.secondaryShow !== false; } }
        ]},
        { title: 'Background', fields: [
            { key: 'bgType', label: 'Type', type: 'select', options: [['none', 'None'], ['solid', 'Solid'], ['gradient', 'Gradient'], ['image', 'Image']], rerender: true },
            { key: 'bg1', label: 'Colour', type: 'color', when: function (p) { return p.bgType === 'solid' || p.bgType === 'gradient'; } },
            { key: 'bg2', label: 'Colour 2', type: 'color', when: function (p) { return p.bgType === 'gradient'; } },
            { key: 'bgAngle', label: 'Gradient angle', type: 'range', min: 0, max: 360, step: 1, suffix: '°', def: 135, when: function (p) { return p.bgType === 'gradient'; } },
            { key: 'bgImage', label: 'Image URL', type: 'text', when: function (p) { return p.bgType === 'image'; }, placeholder: 'https://…' },
            { key: 'overlay', label: 'Image darken', type: 'range', min: 0, max: 90, step: 1, suffix: '%', def: 45, when: function (p) { return p.bgType === 'image'; }, hint: 'Darkens the image so text stays readable.' },
            { key: 'tone', label: 'Text tone', type: 'select', options: [['auto', 'Auto'], ['light', 'Light'], ['dark', 'Dark']], hint: 'Auto uses light text on image/gradient backgrounds.' }
        ]},
        styleGroup() ]; }
    });
    // Feature Grid: icon + title + description cards in 2/3/4 responsive columns.
    def('feature-grid', {
        label: 'Feature Grid', icon: 'bi-grid-3x3-gap', category: 'Sections', hot: true,
        _desc: 'A responsive grid of icon + title + description cards — the classic “what you get” section. 2, 3 or 4 columns, card or plain style.',
        defaults: function () { return { heading: 'Everything you need', subhead: '', columns: '3', align: 'left', variant: 'card', iconStyle: 'tile',
            items: [
                { icon: 'bi-lightning-charge', title: 'Fast', text: 'Describe the benefit in a sentence or two.' },
                { icon: 'bi-shield-check', title: 'Secure', text: 'Describe the benefit in a sentence or two.' },
                { icon: 'bi-magic', title: 'Simple', text: 'Describe the benefit in a sentence or two.' }
            ] }; },
        render: function (n) {
            var p = n.props || {};
            var cols = ['2', '3', '4'].indexOf(String(p.columns)) > -1 ? String(p.columns) : '3';
            var head = ((p.heading || '').trim() || (p.subhead || '').trim())
                ? '<div class="ppd-c-feat-head">' + ((p.heading || '').trim() ? '<h2>' + esc(p.heading) + '</h2>' : '') + ((p.subhead || '').trim() ? '<p>' + esc(p.subhead) + '</p>' : '') + '</div>' : '';
            var items = (Array.isArray(p.items) ? p.items : []).map(function (it) {
                return '<div class="ppd-c-feat' + (p.variant === 'plain' ? ' is-plain' : '') + ' align-' + (p.align === 'center' ? 'center' : 'left') + '">'
                    + '<div class="ppd-c-feat-ic' + (p.iconStyle === 'plain' ? ' is-plain' : '') + '">' + icon(it.icon || 'bi-star') + '</div>'
                    + '<h3>' + esc(it.title || 'Feature') + '</h3>'
                    + ((it.text || '').trim() ? '<p>' + esc(it.text) + '</p>' : '') + '</div>';
            }).join('');
            return '<section class="ppd-c-features"><div class="ppd-c-feat-wrap">' + head + '<div class="ppd-c-feat-grid cols-' + cols + '">' + items + '</div></div></section>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Section heading', type: 'text', placeholder: 'optional' },
            { key: 'subhead', label: 'Sub-heading', type: 'textarea', placeholder: 'optional' },
            { key: 'columns', label: 'Columns', type: 'select', options: [['2', '2'], ['3', '3'], ['4', '4']] },
            { key: 'align', label: 'Card text', type: 'select', options: [['left', 'Left'], ['center', 'Center']] },
            { key: 'variant', label: 'Card style', type: 'select', options: [['card', 'Card (bordered)'], ['plain', 'Plain']] },
            { key: 'iconStyle', label: 'Icon style', type: 'select', options: [['tile', 'Colour tile'], ['plain', 'Plain']] }
        ]),
        { title: 'Features', fields: [
            { key: 'items', label: 'Features', type: 'list', itemLabel: 'Feature', addLabel: 'Add feature', default: { icon: 'bi-star', title: 'New feature', text: 'Describe the benefit.' }, item: [
                { key: 'icon', label: 'Icon', type: 'icon' },
                { key: 'title', label: 'Title', type: 'text' },
                { key: 'text', label: 'Text', type: 'textarea' }
            ] }
        ]},
        styleGroup() ]; }
    });
    // Footer: multi-column site footer — brand + tagline + social + link columns
    // (grouped by each link's Column value) + copyright. Brand inherits Style.
    def('footer', {
        label: 'Footer', icon: 'bi-layout-text-window-reverse', category: 'Sections', hot: true,
        _desc: 'A multi-column website footer: brand + tagline + social icons, link columns (grouped by each link’s Column name), and a copyright line. Brand inherits your Style logo.',
        defaults: function () { return { showBrand: true, brand: '', logo: '', tagline: 'A short line about your product or company.',
            links: [
                { group: 'Product', label: 'Features', href: '#features' }, { group: 'Product', label: 'Pricing', href: '#pricing' },
                { group: 'Company', label: 'About', href: '#' }, { group: 'Company', label: 'Contact', href: '#contact' }
            ],
            social: [{ icon: 'bi-twitter-x', href: '#' }, { icon: 'bi-github', href: '#' }, { icon: 'bi-linkedin', href: '#' }],
            copyright: '© {year} {brand}. All rights reserved.', variant: 'light' }; },
        render: function (n) {
            var p = n.props || {}, b = navBrand(p);
            var groups = [], byG = {};
            (Array.isArray(p.links) ? p.links : []).forEach(function (l) { var g = (l.group || '').trim() || 'Links'; if (!byG[g]) { byG[g] = []; groups.push(g); } byG[g].push(l); });
            var cols = groups.map(function (g) { return '<div class="ppd-c-foot-col"><h4>' + esc(g) + '</h4><ul>' + byG[g].map(function (l) { return '<li><a>' + esc(l.label || 'Link') + '</a></li>'; }).join('') + '</ul></div>'; }).join('');
            var social = (Array.isArray(p.social) ? p.social : []).map(function (s) { return '<a class="ppd-c-foot-soc" aria-label="social">' + icon(s.icon || 'bi-link-45deg') + '</a>'; }).join('');
            var brandBlock = '<div class="ppd-c-foot-brandcol"><a class="ppd-c-foot-brand">' + (b.image ? '<img src="' + esc(b.image) + '" alt="">' : '') + (p.showBrand !== false && b.text ? '<span>' + esc(b.text) + '</span>' : '') + '</a>'
                + ((p.tagline || '').trim() ? '<p class="ppd-c-foot-tag">' + esc(p.tagline) + '</p>' : '')
                + (social ? '<div class="ppd-c-foot-socs">' + social + '</div>' : '') + '</div>';
            var copy = esc(String(p.copyright || '').split('{year}').join(new Date().getFullYear()).split('{brand}').join(b.text || ''));
            return '<footer class="ppd-c-foot variant-' + (p.variant === 'dark' ? 'dark' : 'light') + '"><div class="ppd-c-foot-main">' + brandBlock + '<div class="ppd-c-foot-cols">' + cols + '</div></div><div class="ppd-c-foot-bottom"><span>' + copy + '</span></div></footer>';
        },
        schema: function () { return [ generalGroup([
            { key: 'brand', label: 'Brand text', type: 'text', placeholder: 'Inherit from Style' },
            { key: 'logo', label: 'Logo image URL', type: 'text', placeholder: 'Inherit from Style' },
            { key: 'showBrand', label: 'Show brand text', type: 'bool' },
            { key: 'tagline', label: 'Tagline', type: 'textarea' },
            { key: 'variant', label: 'Style', type: 'select', options: [['light', 'Light'], ['dark', 'Dark']] },
            { key: 'copyright', label: 'Copyright', type: 'text', hint: 'Use {year} and {brand} — they’re filled in automatically.' }
        ]),
        { title: 'Link columns', fields: [
            { key: 'links', label: 'Links', type: 'list', itemLabel: 'Link', addLabel: 'Add link', default: { group: 'Links', label: 'New link', href: '#' }, item: [
                { key: 'group', label: 'Column', type: 'text', placeholder: 'e.g. Product' },
                { key: 'label', label: 'Label', type: 'text' },
                { key: 'href', label: 'URL', type: 'text' }
            ] }
        ]},
        { title: 'Social', fields: [
            { key: 'social', label: 'Social icons', type: 'list', itemLabel: 'Icon', addLabel: 'Add social', default: { icon: 'bi-link-45deg', href: '#' }, item: [
                { key: 'icon', label: 'Icon', type: 'icon' },
                { key: 'href', label: 'URL', type: 'text' }
            ] }
        ]},
        styleGroup() ]; }
    });
    // Pricing: tiered plan cards with an optional monthly/annual toggle (pure CSS).
    def('pricing', {
        label: 'Pricing', icon: 'bi-tags', category: 'Sections', hot: true,
        _desc: 'Tiered pricing cards with per-plan feature lists, a “most popular” highlight, and an optional monthly/annual toggle (pure CSS — no JS).',
        defaults: function () { return { heading: 'Simple, transparent pricing', subhead: 'Choose the plan that fits. No hidden fees.', showToggle: true, currency: '$',
            plans: [
                { name: 'Starter', price: '0', priceYear: '0', period: '/mo', badge: '', highlighted: false, desc: 'For side projects.', features: '1 project\nCommunity support\nBasic components', ctaLabel: 'Get started', ctaHref: '#' },
                { name: 'Pro', price: '19', priceYear: '15', period: '/mo', badge: 'Most popular', highlighted: true, desc: 'For growing teams.', features: 'Unlimited projects\nPriority support\nAll components\nCustom domain', ctaLabel: 'Start free trial', ctaHref: '#' },
                { name: 'Business', price: '49', priceYear: '39', period: '/mo', badge: '', highlighted: false, desc: 'For companies.', features: 'Everything in Pro\nSSO & audit logs\nSLA', ctaLabel: 'Contact sales', ctaHref: '#' }
            ] }; },
        render: function (n) {
            var p = n.props || {}, id = 'ppprice-' + (n.id || 'x'), cur = esc(p.currency || '$');
            var plansArr = Array.isArray(p.plans) ? p.plans : [];
            var hasYear = plansArr.some(function (pl) { return String(pl.priceYear || '').trim() !== ''; });
            var toggle = (p.showToggle !== false && hasYear) ? '<div class="ppd-c-price-toggle"><span>Monthly</span><label class="ppd-c-price-sw" for="' + esc(id) + '"><span class="ppd-c-price-knob"></span></label><span>Annual</span></div>' : '';
            var head = ((p.heading || '').trim() || (p.subhead || '').trim()) ? '<div class="ppd-c-price-head">' + ((p.heading || '').trim() ? '<h2>' + esc(p.heading) + '</h2>' : '') + ((p.subhead || '').trim() ? '<p>' + esc(p.subhead) + '</p>' : '') + '</div>' : '';
            var plans = plansArr.map(function (pl) {
                var feats = String(pl.features || '').split('\n').map(function (f) { f = f.trim(); return f ? '<li>' + icon('bi-check-lg') + '<span>' + esc(f) + '</span></li>' : ''; }).join('');
                var priceM = '<span class="ppd-c-price-m">' + cur + esc(pl.price || '0') + '<em>' + esc(pl.period || '') + '</em></span>';
                var priceY = String(pl.priceYear || '').trim() !== '' ? '<span class="ppd-c-price-y">' + cur + esc(pl.priceYear) + '<em>' + esc(pl.period || '') + '</em></span>' : '';
                return '<div class="ppd-c-plan' + (pl.highlighted ? ' is-highlight' : '') + '">'
                    + ((pl.badge || '').trim() ? '<span class="ppd-c-plan-badge">' + esc(pl.badge) + '</span>' : '')
                    + '<div class="ppd-c-plan-name">' + esc(pl.name || 'Plan') + '</div>'
                    + '<div class="ppd-c-plan-price">' + priceM + priceY + '</div>'
                    + ((pl.desc || '').trim() ? '<p class="ppd-c-plan-desc">' + esc(pl.desc) + '</p>' : '')
                    + '<ul class="ppd-c-plan-feats">' + feats + '</ul>'
                    + ((pl.ctaLabel || '').trim() ? '<a class="ppd-c-plan-cta' + (pl.highlighted ? ' is-primary' : '') + '">' + esc(pl.ctaLabel) + '</a>' : '') + '</div>';
            }).join('');
            return '<section class="ppd-c-pricing"><input type="checkbox" class="ppd-c-price-switch" id="' + esc(id) + '">'
                + '<div class="ppd-c-price-wrap">' + head + toggle + '<div class="ppd-c-price-grid">' + plans + '</div></div></section>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text', placeholder: 'optional' },
            { key: 'subhead', label: 'Sub-heading', type: 'textarea', placeholder: 'optional' },
            { key: 'currency', label: 'Currency symbol', type: 'text', placeholder: '$' },
            { key: 'showToggle', label: 'Monthly/Annual toggle', type: 'bool', hint: 'Shown only when plans have an annual price.' }
        ]),
        { title: 'Plans', fields: [
            { key: 'plans', label: 'Plans', type: 'list', itemLabel: 'Plan', addLabel: 'Add plan',
              default: { name: 'New plan', price: '9', priceYear: '', period: '/mo', badge: '', highlighted: false, desc: '', features: 'Feature one\nFeature two', ctaLabel: 'Choose', ctaHref: '#' },
              item: [
                { key: 'name', label: 'Name', type: 'text' },
                { key: 'price', label: 'Price (monthly)', type: 'text' },
                { key: 'priceYear', label: 'Price (annual)', type: 'text', placeholder: 'optional' },
                { key: 'period', label: 'Period', type: 'text', placeholder: '/mo' },
                { key: 'badge', label: 'Badge', type: 'text', placeholder: 'e.g. Most popular' },
                { key: 'highlighted', label: 'Highlight this plan', type: 'bool' },
                { key: 'desc', label: 'Description', type: 'text' },
                { key: 'features', label: 'Features (one per line)', type: 'textarea' },
                { key: 'ctaLabel', label: 'Button label', type: 'text' },
                { key: 'ctaHref', label: 'Button link', type: 'text' }
              ] }
        ]},
        styleGroup() ]; }
    });
    // Testimonials: social-proof cards with quote, star rating and author.
    def('testimonials', {
        label: 'Testimonials', icon: 'bi-chat-quote', category: 'Sections', hot: true,
        _desc: 'Social-proof cards: a quote, star rating, and author (avatar + name + role) in 1/2/3 responsive columns.',
        defaults: function () { return { heading: 'Loved by teams everywhere', subhead: '', columns: '3',
            items: [
                { quote: 'This tool completely changed how we ship pages. Fast and delightful.', name: 'Alex Rivera', role: 'Founder, Acme', avatar: '', rating: 5 },
                { quote: 'We replaced three tools with one. The team adopted it in a day.', name: 'Sam Chen', role: 'PM, Globex', avatar: '', rating: 5 },
                { quote: 'Beautiful defaults and endless flexibility. Highly recommend.', name: 'Jordan Lee', role: 'Designer, Initech', avatar: '', rating: 4 }
            ] }; },
        render: function (n) {
            var p = n.props || {}, cols = ['1', '2', '3'].indexOf(String(p.columns)) > -1 ? String(p.columns) : '3';
            var head = ((p.heading || '').trim() || (p.subhead || '').trim()) ? '<div class="ppd-c-tm-head">' + ((p.heading || '').trim() ? '<h2>' + esc(p.heading) + '</h2>' : '') + ((p.subhead || '').trim() ? '<p>' + esc(p.subhead) + '</p>' : '') + '</div>' : '';
            var items = (Array.isArray(p.items) ? p.items : []).map(function (it) {
                var r = Math.max(0, Math.min(5, parseInt(it.rating, 10) || 0)), stars = '';
                for (var i = 0; i < 5; i++) stars += icon(i < r ? 'bi-star-fill' : 'bi-star');
                var initial = esc(String(it.name || '?').trim().charAt(0).toUpperCase() || '?');
                var av = (it.avatar || '').trim() ? '<img class="ppd-c-tm-av" src="' + esc(it.avatar) + '" alt="">' : '<span class="ppd-c-tm-av ppd-c-tm-av-ph">' + initial + '</span>';
                return '<figure class="ppd-c-tm">' + (r ? '<div class="ppd-c-tm-stars">' + stars + '</div>' : '')
                    + '<blockquote class="ppd-c-tm-quote">' + esc(it.quote || '') + '</blockquote>'
                    + '<figcaption class="ppd-c-tm-by">' + av + '<span class="ppd-c-tm-meta"><b>' + esc(it.name || '') + '</b>' + ((it.role || '').trim() ? '<em>' + esc(it.role) + '</em>' : '') + '</span></figcaption></figure>';
            }).join('');
            return '<section class="ppd-c-testimonials"><div class="ppd-c-tm-wrap">' + head + '<div class="ppd-c-tm-grid cols-' + cols + '">' + items + '</div></div></section>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text', placeholder: 'optional' },
            { key: 'subhead', label: 'Sub-heading', type: 'textarea', placeholder: 'optional' },
            { key: 'columns', label: 'Columns', type: 'select', options: [['1', '1'], ['2', '2'], ['3', '3']] }
        ]),
        { title: 'Testimonials', fields: [
            { key: 'items', label: 'Quotes', type: 'list', itemLabel: 'Quote', addLabel: 'Add testimonial',
              default: { quote: 'A short, specific quote about the result they got.', name: 'Full Name', role: 'Role, Company', avatar: '', rating: 5 },
              item: [
                { key: 'quote', label: 'Quote', type: 'textarea' },
                { key: 'name', label: 'Name', type: 'text' },
                { key: 'role', label: 'Role / company', type: 'text' },
                { key: 'avatar', label: 'Avatar URL', type: 'text', placeholder: 'optional' },
                { key: 'rating', label: 'Rating (0–5)', type: 'range', min: 0, max: 5, step: 1, def: 5 }
              ] }
        ]},
        styleGroup() ]; }
    });
    // Contact Form: a working, database-free contact form. The build render emits
    // a self-contained inline PHP handler (validates → honeypot → mail()); the
    // recipient is baked server-side so it can never be used as an open relay.
    def('contact-form', {
        label: 'Contact Form', icon: 'bi-envelope-paper', category: 'Sections', hot: true,
        _desc: 'A working contact form that emails your inbox — no database needed. Set the recipient; messages are validated, spam-guarded (honeypot) and sent server-side via the host mailer.',
        defaults: function () { return { heading: 'Get in touch', subhead: 'We’ll get back to you within one business day.', recipient: '', subject: 'New message from your website', showSubjectField: false, showPhone: false, buttonLabel: 'Send message', successMsg: 'Thanks! Your message has been sent — we’ll be in touch soon.', align: 'center' }; },
        render: function (n) {
            var p = n.props || {};
            var head = ((p.heading || '').trim() || (p.subhead || '').trim()) ? '<div class="ppd-c-contact-head">' + ((p.heading || '').trim() ? '<h2>' + esc(p.heading) + '</h2>' : '') + ((p.subhead || '').trim() ? '<p>' + esc(p.subhead) + '</p>' : '') + '</div>' : '';
            var fields = '<div class="ppd-c-cf-field"><label>Name</label><input type="text" placeholder="Your name"></div>'
                + (p.showPhone ? '<div class="ppd-c-cf-field"><label>Phone</label><input type="tel"></div>' : '')
                + '<div class="ppd-c-cf-field"><label>Email</label><input type="email" placeholder="you@example.com"></div>'
                + (p.showSubjectField ? '<div class="ppd-c-cf-field"><label>Subject</label><input type="text"></div>' : '')
                + '<div class="ppd-c-cf-field"><label>Message</label><textarea placeholder="How can we help?" rows="5"></textarea></div>';
            var note = !(p.recipient || '').trim() ? '<div class="ppd-c-contact-note">' + icon('bi-info-circle') + '<span>Set a recipient email in the inspector to receive messages.</span></div>' : '';
            return '<section class="ppd-c-contact align-' + (p.align === 'left' ? 'left' : 'center') + '"><div class="ppd-c-contact-wrap">' + head
                + '<form class="ppd-c-contact-form">' + fields + '<button type="button" class="ppd-c-contact-btn">' + esc(p.buttonLabel || 'Send message') + '</button>' + note + '</form></div></section>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text' },
            { key: 'subhead', label: 'Sub-heading', type: 'textarea' },
            { key: 'recipient', label: 'Send messages to', type: 'text', placeholder: 'you@example.com', hint: 'Your email address. Kept server-side — visitors never see it. Uses the host’s mail() function.' },
            { key: 'subject', label: 'Email subject', type: 'text' }
        ]),
        { title: 'Fields & labels', fields: [
            { key: 'showPhone', label: 'Include phone field', type: 'bool' },
            { key: 'showSubjectField', label: 'Include subject field', type: 'bool' },
            { key: 'buttonLabel', label: 'Button label', type: 'text' },
            { key: 'successMsg', label: 'Success message', type: 'textarea' },
            { key: 'align', label: 'Align', type: 'select', options: [['center', 'Center'], ['left', 'Left']] }
        ]},
        styleGroup() ]; }
    });
    // Announcement Bar: a slim, dismissible top strip (pure-CSS dismiss).
    def('announcement-bar', {
        label: 'Announcement Bar', icon: 'bi-megaphone', category: 'Sections', hot: true,
        _desc: 'A slim, dismissible top strip for a promo or notice — text + optional link, custom colours. Dismiss is pure CSS (no JS).',
        defaults: function () { return { text: '🎉 New: standalone sites are here — build a landing page in minutes.', linkLabel: 'Learn more', linkHref: '#', bg: '', color: '', dismissible: true, align: 'center' }; },
        render: function (n) {
            var p = n.props || {}, id = 'ppannc-' + (n.id || 'x');
            var sty = ''; if ((p.bg || '').trim()) sty += 'background:' + esc(p.bg) + ';'; if ((p.color || '').trim()) sty += 'color:' + esc(p.color) + ';';
            var link = (p.linkLabel || '').trim() ? ' <a class="ppd-c-annc-link">' + esc(p.linkLabel) + '</a>' : '';
            var close = p.dismissible !== false ? '<label class="ppd-c-annc-close" for="' + esc(id) + '" aria-label="Dismiss">&times;</label>' : '';
            return '<div class="ppd-c-annc-wrap"><input type="checkbox" class="ppd-c-annc-x" id="' + esc(id) + '"><div class="ppd-c-annc align-' + (p.align === 'left' ? 'left' : 'center') + '"' + (sty ? ' style="' + sty + '"' : '') + '><div class="ppd-c-annc-in"><span>' + esc(p.text || '') + '</span>' + link + '</div>' + close + '</div></div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'text', label: 'Text', type: 'textarea' },
            { key: 'linkLabel', label: 'Link label', type: 'text', placeholder: 'optional' },
            { key: 'linkHref', label: 'Link URL', type: 'text' },
            { key: 'bg', label: 'Background', type: 'color', hint: 'Blank = theme primary.' },
            { key: 'color', label: 'Text colour', type: 'color' },
            { key: 'dismissible', label: 'Show dismiss (×)', type: 'bool' },
            { key: 'align', label: 'Align', type: 'select', options: [['center', 'Center'], ['left', 'Left']] }
        ]), styleGroup() ]; }
    });
    // Logo Cloud: a "trusted by" strip, optionally an auto-scrolling marquee (CSS).
    def('logo-cloud', {
        label: 'Logo Cloud', icon: 'bi-collection', category: 'Sections', hot: true,
        _desc: 'A “trusted by” strip of client/partner logos — grayscale by default, with an optional auto-scrolling marquee (pure CSS).',
        defaults: function () { return { heading: 'Trusted by teams at', grayscale: true, marquee: false, logos: [{ image: '', alt: 'Logo' }, { image: '', alt: 'Logo' }, { image: '', alt: 'Logo' }, { image: '', alt: 'Logo' }, { image: '', alt: 'Logo' }] }; },
        render: function (n) {
            var p = n.props || {}, dyn = !!p.table;
            var items = dyn
                ? [0, 1, 2, 3, 4].map(function () { return '<span class="ppd-c-logo ppd-c-logo-ph">' + icon('bi-image') + '</span>'; }).join('')
                : (Array.isArray(p.logos) ? p.logos : []).map(function (l) { return (l.image || '').trim() ? '<span class="ppd-c-logo"><img src="' + esc(l.image) + '" alt="' + esc(l.alt || '') + '"></span>' : '<span class="ppd-c-logo ppd-c-logo-ph">' + icon('bi-image') + '</span>'; }).join('');
            var body = p.marquee
                ? '<div class="ppd-c-logos-marq"><div class="ppd-c-logos-track">' + items + '</div><div class="ppd-c-logos-track" aria-hidden="true">' + items + '</div></div>'
                : '<div class="ppd-c-logos-row">' + items + '</div>';
            var badge = dyn ? ' <i class="bi bi-database ppd-dynbadge" title="Live from ' + esc(title(p.table)) + '"></i>' : '';
            return '<section class="ppd-c-logos' + (p.grayscale !== false ? ' is-gray' : '') + (p.marquee ? ' is-marquee' : '') + '"><div class="ppd-c-logos-wrap">' + ((p.heading || '').trim() || dyn ? '<p class="ppd-c-logos-h">' + esc(p.heading || '') + badge + '</p>' : '') + body + '</div></section>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text', placeholder: 'optional' },
            { key: 'grayscale', label: 'Grayscale logos', type: 'bool', hint: 'Desaturate until hover.' },
            { key: 'marquee', label: 'Auto-scroll (marquee)', type: 'bool', rerender: true }
        ]),
        { title: 'Live data (optional)', fields: [
            { key: 'table', label: 'Logos from table', type: 'dbtable', rerender: true, hint: 'Optional — pull one logo per row from a table instead of the manual list below.' },
            { key: 'imageColumn', label: 'Image URL column', type: 'dbcolumn', tableKey: 'table', hint: 'Column holding each logo’s image URL.' },
            { key: 'altColumn', label: 'Name / alt column', type: 'dbcolumn', tableKey: 'table', hint: 'Optional — column with the company name (used as alt text).' },
            { key: 'where', label: 'Filter (WHERE)', type: 'text', full: true, placeholder: "active = 1", hint: 'Optional SQL condition — supports SQL variables (:userid, :today…).' },
            { key: 'orderColumn', label: 'Order by column', type: 'dbcolumn', tableKey: 'table', hint: 'Column to sort by (blank = natural order).' },
            { key: 'orderDir', label: 'Order', type: 'select', options: ['ASC', 'DESC'] },
            { key: 'limit', label: 'Max logos', type: 'number', hint: 'How many rows to show (1–60).' }
        ]},
        { title: 'Logos (manual)', fields: [
            { key: 'logos', label: 'Logos', type: 'list', itemLabel: 'Logo', addLabel: 'Add logo', default: { image: '', alt: 'Logo' }, item: [
                { key: 'image', label: 'Image URL', type: 'text', placeholder: 'https://… (SVG/PNG)' },
                { key: 'alt', label: 'Name / alt', type: 'text' }
            ] }
        ]},
        styleGroup() ]; }
    });
    // Stats Counter: big metrics; the BUILD adds a count-up animation (canvas shows
    // the final number statically). Values may be decimals (99.9) or plain (10).
    def('stats-counter', {
        label: 'Stats', icon: 'bi-graph-up-arrow', category: 'Sections', hot: true,
        _desc: 'Big headline metrics with an animated count-up on scroll — great for social proof (users, uptime, revenue).',
        defaults: function () { return { heading: '', columns: '4',
            items: [{ prefix: '', value: '10', suffix: 'k+', label: 'Active users' }, { prefix: '', value: '99.9', suffix: '%', label: 'Uptime' }, { prefix: '$', value: '2', suffix: 'M', label: 'Processed' }, { prefix: '', value: '150', suffix: '+', label: 'Countries' }] }; },
        render: function (n) {
            var p = n.props || {}, cols = ['2', '3', '4'].indexOf(String(p.columns)) > -1 ? String(p.columns) : '4';
            var items = (Array.isArray(p.items) ? p.items : []).map(function (it) {
                var dyn = !!it.table;
                var val = dyn
                    ? '<span class="ppd-c-stat-val">1,248</span><i class="bi bi-database ppd-dynbadge" title="Live from ' + esc(title(it.table)) + '"></i>'
                    : (function () { var v = esc(String(it.value == null ? '' : it.value)); return '<span class="ppd-c-stat-val" data-to="' + v + '">' + v + '</span>'; })();
                return '<div class="ppd-c-stat"><div class="ppd-c-stat-num"><span class="ppd-c-stat-pre">' + esc(it.prefix || '') + '</span>' + val + '<span class="ppd-c-stat-suf">' + esc(it.suffix || '') + '</span></div><div class="ppd-c-stat-lbl">' + esc(it.label || '') + '</div></div>';
            }).join('');
            return '<section class="ppd-c-stats"><div class="ppd-c-stats-wrap">' + ((p.heading || '').trim() ? '<h2 class="ppd-c-stats-h">' + esc(p.heading) + '</h2>' : '') + '<div class="ppd-c-stats-grid cols-' + cols + '">' + items + '</div></div></section>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text', placeholder: 'optional' },
            { key: 'columns', label: 'Columns', type: 'select', options: [['2', '2'], ['3', '3'], ['4', '4']] }
        ]),
        { title: 'Metrics', fields: [
            { key: 'items', label: 'Metrics', type: 'list', itemLabel: 'Metric', addLabel: 'Add metric', default: { prefix: '', value: '100', suffix: '+', label: 'Label' }, item: [
                { key: 'label', label: 'Label', type: 'text' },
                { key: 'prefix', label: 'Prefix', type: 'text', placeholder: 'e.g. $' },
                { key: 'suffix', label: 'Suffix', type: 'text', placeholder: 'e.g. k+ / % / M' },
                { key: 'table', label: 'Live from table', type: 'dbtable', hint: 'Optional — makes this metric a live DB count/total. Blank = the manual Number below.' },
                { key: 'agg', label: 'Aggregate', type: 'select', options: ['count', 'sum', 'avg', 'min', 'max'], hint: 'count = rows · sum/avg/min/max = over the column.' },
                { key: 'column', label: 'Column', type: 'column', fromItem: 'table', hint: 'Number column for sum / avg / min / max.' },
                { key: 'where', label: 'Filter (WHERE)', type: 'text', hint: 'Optional SQL condition — supports SQL variables (:userid, :today…).' },
                { key: 'decimals', label: 'Decimals', type: 'number', hint: 'Digits after the decimal point for a live value (0–4).' },
                { key: 'abbreviate', label: 'Abbreviate (1.2k)', type: 'bool', boolLabel: 'shorten big numbers', hint: '48,200 → 48.2k for a live value.' },
                { key: 'value', label: 'Number (manual)', type: 'text', placeholder: 'e.g. 99.9', hint: 'Shown when no table is set.' }
            ] }
        ]},
        styleGroup() ]; }
    });
    // CTA Band: a full-width call-to-action strip (reuses the hero bg + button CSS).
    def('cta-band', {
        label: 'CTA Band', icon: 'bi-badge-ad', category: 'Sections', hot: true,
        _desc: 'A full-width call-to-action band: headline + subtext + button(s) over a solid or gradient background.',
        defaults: function () { return { heading: 'Ready to get started?', subhead: 'Join thousands building faster today.', primaryLabel: 'Get started', primaryHref: '#', secondaryShow: false, secondaryLabel: 'Talk to sales', secondaryHref: '#', bgType: 'gradient', bg1: '#4f46e5', bg2: '#7c3aed', bgAngle: 135, tone: 'auto', align: 'center' }; },
        render: function (n) {
            var p = n.props || {};
            var tone = heroToneCls({ tone: p.tone, bgType: p.bgType });
            var btns = '<div class="ppd-c-cta-btns">'
                + ((p.primaryLabel || '').trim() ? '<a class="ppd-c-hero-btn is-primary">' + esc(p.primaryLabel) + '</a>' : '')
                + (p.secondaryShow && (p.secondaryLabel || '').trim() ? '<a class="ppd-c-hero-btn is-ghost">' + esc(p.secondaryLabel) + '</a>' : '') + '</div>';
            return '<section class="ppd-c-cta align-' + (p.align === 'left' ? 'left' : 'center') + tone + '" style="' + heroBgCss(p) + '"><div class="ppd-c-cta-in"><div class="ppd-c-cta-txt"><h2>' + esc(p.heading || '') + '</h2>' + ((p.subhead || '').trim() ? '<p>' + esc(p.subhead) + '</p>' : '') + '</div>' + btns + '</div></section>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text' },
            { key: 'subhead', label: 'Sub-heading', type: 'textarea' },
            { key: 'align', label: 'Align', type: 'select', options: [['center', 'Center'], ['left', 'Left (buttons right)']] }
        ]),
        { title: 'Buttons', fields: [
            { key: 'primaryLabel', label: 'Primary label', type: 'text' },
            { key: 'primaryHref', label: 'Primary link', type: 'text' },
            { key: 'secondaryShow', label: 'Show secondary', type: 'bool', rerender: true },
            { key: 'secondaryLabel', label: 'Secondary label', type: 'text', when: function (p) { return !!p.secondaryShow; } },
            { key: 'secondaryHref', label: 'Secondary link', type: 'text', when: function (p) { return !!p.secondaryShow; } }
        ]},
        { title: 'Background', fields: [
            { key: 'bgType', label: 'Type', type: 'select', options: [['solid', 'Solid'], ['gradient', 'Gradient']], rerender: true },
            { key: 'bg1', label: 'Colour', type: 'color' },
            { key: 'bg2', label: 'Colour 2', type: 'color', when: function (p) { return p.bgType === 'gradient'; } },
            { key: 'bgAngle', label: 'Angle', type: 'range', min: 0, max: 360, step: 1, suffix: '°', def: 135, when: function (p) { return p.bgType === 'gradient'; } },
            { key: 'tone', label: 'Text tone', type: 'select', options: [['auto', 'Auto'], ['light', 'Light'], ['dark', 'Dark']] }
        ]},
        styleGroup() ]; }
    });
    // Team Grid: photo + name + role + bio + social links (per member, one
    // "bi-icon | url" per line).
    def('team-grid', {
        label: 'Team', icon: 'bi-people', category: 'Sections', hot: true,
        _desc: 'Meet-the-team cards: photo (or initial), name, role, short bio and social links.',
        defaults: function () { return { heading: 'Meet the team', subhead: '', columns: '3',
            items: [
                { photo: '', name: 'Jane Doe', role: 'CEO & Founder', bio: '', socials: 'bi-linkedin | #\nbi-twitter-x | #' },
                { photo: '', name: 'John Smith', role: 'CTO', bio: '', socials: '' },
                { photo: '', name: 'Amy Lee', role: 'Head of Design', bio: '', socials: '' }
            ] }; },
        render: function (n) {
            var p = n.props || {}, cols = ['2', '3', '4'].indexOf(String(p.columns)) > -1 ? String(p.columns) : '3';
            var head = ((p.heading || '').trim() || (p.subhead || '').trim()) ? '<div class="ppd-c-team-head">' + ((p.heading || '').trim() ? '<h2>' + esc(p.heading) + '</h2>' : '') + ((p.subhead || '').trim() ? '<p>' + esc(p.subhead) + '</p>' : '') + '</div>' : '';
            var items = (Array.isArray(p.items) ? p.items : []).map(function (m) {
                var soc = String(m.socials || '').split('\n').map(function (s) { var parts = s.split('|'); var ic = (parts[0] || '').trim(); return ic ? '<a class="ppd-c-team-soc" aria-label="social">' + icon(ic) + '</a>' : ''; }).join('');
                var ph = (m.photo || '').trim() ? '<img class="ppd-c-team-ph" src="' + esc(m.photo) + '" alt="">' : '<span class="ppd-c-team-ph ppd-c-team-ph-x">' + esc(String(m.name || '?').trim().charAt(0).toUpperCase() || '?') + '</span>';
                return '<div class="ppd-c-team">' + ph + '<div class="ppd-c-team-name">' + esc(m.name || '') + '</div><div class="ppd-c-team-role">' + esc(m.role || '') + '</div>'
                    + ((m.bio || '').trim() ? '<p class="ppd-c-team-bio">' + esc(m.bio) + '</p>' : '') + (soc ? '<div class="ppd-c-team-socs">' + soc + '</div>' : '') + '</div>';
            }).join('');
            return '<section class="ppd-c-teams"><div class="ppd-c-team-wrap">' + head + '<div class="ppd-c-team-grid cols-' + cols + '">' + items + '</div></div></section>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text', placeholder: 'optional' },
            { key: 'subhead', label: 'Sub-heading', type: 'textarea', placeholder: 'optional' },
            { key: 'columns', label: 'Columns', type: 'select', options: [['2', '2'], ['3', '3'], ['4', '4']] }
        ]),
        { title: 'Members', fields: [
            { key: 'items', label: 'Members', type: 'list', itemLabel: 'Member', addLabel: 'Add member', default: { photo: '', name: 'Full Name', role: 'Role', bio: '', socials: '' }, item: [
                { key: 'photo', label: 'Photo URL', type: 'text', placeholder: 'optional' },
                { key: 'name', label: 'Name', type: 'text' },
                { key: 'role', label: 'Role', type: 'text' },
                { key: 'bio', label: 'Bio', type: 'textarea', placeholder: 'optional' },
                { key: 'socials', label: 'Socials', type: 'textarea', placeholder: 'bi-linkedin | https://…\nbi-twitter-x | https://…', hint: 'One per line: bootstrap-icon | URL.' }
            ] }
        ]},
        styleGroup() ]; }
    });
    // FAQ: native <details> accordion — no JS.
    def('faq', {
        label: 'FAQ', icon: 'bi-patch-question', category: 'Sections', hot: true,
        _desc: 'A frequently-asked-questions accordion (native <details>, no JS) — click a question to expand its answer.',
        defaults: function () { return { heading: 'Frequently asked questions', subhead: '', columns: '1', openFirst: true,
            items: [
                { q: 'How does billing work?', a: 'You’re billed monthly or annually and can cancel anytime.' },
                { q: 'Is there a free plan?', a: 'Yes — the Starter plan is free forever.' },
                { q: 'Can I use my own domain?', a: 'Absolutely, on any paid plan.' }
            ] }; },
        render: function (n) {
            var p = n.props || {}, cols = String(p.columns) === '2' ? '2' : '1';
            var head = ((p.heading || '').trim() || (p.subhead || '').trim()) ? '<div class="ppd-c-faq-head">' + ((p.heading || '').trim() ? '<h2>' + esc(p.heading) + '</h2>' : '') + ((p.subhead || '').trim() ? '<p>' + esc(p.subhead) + '</p>' : '') + '</div>' : '';
            var items = (Array.isArray(p.items) ? p.items : []).map(function (it, i) {
                return '<details class="ppd-c-faq-item"' + ((p.openFirst !== false && i === 0) ? ' open' : '') + '><summary class="ppd-c-faq-q"><span>' + esc(it.q || '') + '</span><i class="bi bi-chevron-down ppd-c-faq-ic"></i></summary><div class="ppd-c-faq-a">' + esc(it.a || '') + '</div></details>';
            }).join('');
            return '<section class="ppd-c-faq"><div class="ppd-c-faq-wrap">' + head + '<div class="ppd-c-faq-list cols-' + cols + '">' + items + '</div></div></section>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text', placeholder: 'optional' },
            { key: 'subhead', label: 'Sub-heading', type: 'textarea', placeholder: 'optional' },
            { key: 'columns', label: 'Columns', type: 'select', options: [['1', '1 (stacked)'], ['2', '2']] },
            { key: 'openFirst', label: 'Open first item', type: 'bool' }
        ]),
        { title: 'Questions', fields: [
            { key: 'items', label: 'Q & A', type: 'list', itemLabel: 'Question', addLabel: 'Add question', default: { q: 'New question?', a: 'The answer.' }, item: [
                { key: 'q', label: 'Question', type: 'text' },
                { key: 'a', label: 'Answer', type: 'textarea' }
            ] }
        ]},
        styleGroup() ]; }
    });
    // Carousel: horizontal scroll-snap slider (no JS; swipe/scroll).
    def('carousel', {
        label: 'Carousel', icon: 'bi-images', category: 'Sections', hot: true,
        _desc: 'A swipeable image carousel (CSS scroll-snap, no JS) — great for screenshots, photos or highlights.',
        defaults: function () { return { heading: '', aspect: '16/9', peek: false,
            slides: [{ image: '', caption: 'Slide one' }, { image: '', caption: 'Slide two' }, { image: '', caption: 'Slide three' }] }; },
        render: function (n) {
            var p = n.props || {}, ar = ['16/9', '4/3', '1/1', '3/2', '21/9'].indexOf(p.aspect) > -1 ? p.aspect : '16/9';
            var slides = (Array.isArray(p.slides) ? p.slides : []).map(function (s) {
                var img = (s.image || '').trim() ? '<img src="' + esc(s.image) + '" alt="' + esc(s.caption || '') + '">' : '<div class="ppd-c-carou-ph">' + icon('bi-image') + '</div>';
                return '<div class="ppd-c-carou-slide" style="aspect-ratio:' + esc(ar) + '">' + img + ((s.caption || '').trim() ? '<div class="ppd-c-carou-cap">' + esc(s.caption) + '</div>' : '') + '</div>';
            }).join('');
            return '<section class="ppd-c-carou' + (p.peek ? ' is-peek' : '') + '"><div class="ppd-c-carou-wrap">' + ((p.heading || '').trim() ? '<h2 class="ppd-c-carou-h">' + esc(p.heading) + '</h2>' : '') + '<div class="ppd-c-carou-track">' + slides + '</div><div class="ppd-c-carou-hint">' + icon('bi-arrow-left-right') + ' Scroll or swipe</div></div></section>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text', placeholder: 'optional' },
            { key: 'aspect', label: 'Slide ratio', type: 'select', options: ['16/9', '4/3', '3/2', '1/1', '21/9'] },
            { key: 'peek', label: 'Peek next slide', type: 'bool', hint: 'Show a sliver of the neighbouring slides.' }
        ]),
        { title: 'Slides', fields: [
            { key: 'slides', label: 'Slides', type: 'list', itemLabel: 'Slide', addLabel: 'Add slide', default: { image: '', caption: '' }, item: [
                { key: 'image', label: 'Image URL', type: 'text' },
                { key: 'caption', label: 'Caption', type: 'text', placeholder: 'optional' }
            ] }
        ]},
        styleGroup() ]; }
    });
    // Gallery: responsive image grid + pure-CSS (:target) lightbox.
    def('gallery', {
        label: 'Gallery', icon: 'bi-grid-1x2', category: 'Sections', hot: true,
        _desc: 'A responsive image gallery with a click-to-zoom lightbox (pure CSS :target, no JS).',
        defaults: function () { return { heading: '', columns: '3', gap: 'md', lightbox: true,
            images: [{ image: '', alt: '' }, { image: '', alt: '' }, { image: '', alt: '' }, { image: '', alt: '' }, { image: '', alt: '' }, { image: '', alt: '' }] }; },
        render: function (n) {
            var p = n.props || {}, cols = ['2', '3', '4'].indexOf(String(p.columns)) > -1 ? String(p.columns) : '3', gid = 'ppgal-' + (n.id || 'x');
            var lb = p.lightbox !== false;
            var cells = (Array.isArray(p.images) ? p.images : []).map(function (im, i) {
                var src = (im.image || '').trim();
                var thumb = src ? '<img src="' + esc(src) + '" alt="' + esc(im.alt || '') + '" loading="lazy">' : '<div class="ppd-c-gal-ph">' + icon('bi-image') + '</div>';
                if (lb && src) return '<a class="ppd-c-gal-cell" href="#' + esc(gid) + '-' + i + '">' + thumb + '</a><div class="ppd-c-gal-lb" id="' + esc(gid) + '-' + i + '"><a class="ppd-c-gal-lb-bg" href="#" aria-label="Close"></a><img src="' + esc(src) + '" alt="' + esc(im.alt || '') + '"><a class="ppd-c-gal-lb-x" href="#" aria-label="Close">&times;</a></div>';
                return '<span class="ppd-c-gal-cell">' + thumb + '</span>';
            }).join('');
            return '<section class="ppd-c-gallery gap-' + (['sm', 'md', 'lg'].indexOf(p.gap) > -1 ? p.gap : 'md') + '"><div class="ppd-c-gal-wrap">' + ((p.heading || '').trim() ? '<h2 class="ppd-c-gal-h">' + esc(p.heading) + '</h2>' : '') + '<div class="ppd-c-gal-grid cols-' + cols + '">' + cells + '</div></div></section>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text', placeholder: 'optional' },
            { key: 'columns', label: 'Columns', type: 'select', options: [['2', '2'], ['3', '3'], ['4', '4']] },
            { key: 'gap', label: 'Gap', type: 'select', options: [['sm', 'Small'], ['md', 'Medium'], ['lg', 'Large']] },
            { key: 'lightbox', label: 'Click to zoom (lightbox)', type: 'bool' }
        ]),
        { title: 'Images', fields: [
            { key: 'images', label: 'Images', type: 'list', itemLabel: 'Image', addLabel: 'Add image', default: { image: '', alt: '' }, item: [
                { key: 'image', label: 'Image URL', type: 'text' },
                { key: 'alt', label: 'Alt text', type: 'text', placeholder: 'optional' }
            ] }
        ]},
        styleGroup() ]; }
    });
    // Countdown: live timer to a target date. BUILD emits a ticking script;
    // canvas shows a static preview.
    def('countdown', {
        label: 'Countdown', icon: 'bi-hourglass-split', category: 'Sections', hot: true,
        _desc: 'A live countdown timer to a target date/time — perfect for launches, sales and events.',
        defaults: function () { return { heading: '', target: '', expiredText: 'This event has started!', variant: 'boxes', align: 'center' }; },
        render: function (n) {
            var p = n.props || {}, id = 'ppcd-' + (n.id || 'x');
            var units = [['days', 'Days'], ['hours', 'Hours'], ['mins', 'Mins'], ['secs', 'Secs']];
            var cells = units.map(function (u) { return '<div class="ppd-c-cd-cell"><span class="ppd-c-cd-num" data-u="' + u[0] + '">00</span><span class="ppd-c-cd-lbl">' + u[1] + '</span></div>'; }).join('<span class="ppd-c-cd-sep">:</span>');
            return '<section class="ppd-c-countdown align-' + (p.align === 'left' ? 'left' : 'center') + '"><div class="ppd-c-cd-wrap">' + ((p.heading || '').trim() ? '<h2 class="ppd-c-cd-h">' + esc(p.heading) + '</h2>' : '') + '<div class="ppd-c-cd ' + (p.variant === 'plain' ? 'is-plain' : 'is-boxes') + '" id="' + esc(id) + '" data-to="' + esc(String(p.target || '')) + '" data-expired="' + esc(p.expiredText || '') + '">' + cells + '</div></div></section>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text', placeholder: 'optional' },
            { key: 'target', label: 'Target date/time', type: 'text', placeholder: '2026-12-31T23:59', hint: 'ISO date-time the timer counts down to, e.g. 2026-12-31T23:59.' },
            { key: 'expiredText', label: 'When finished', type: 'text' },
            { key: 'variant', label: 'Style', type: 'select', options: [['boxes', 'Boxes'], ['plain', 'Plain']] },
            { key: 'align', label: 'Align', type: 'select', options: [['center', 'Center'], ['left', 'Left']] }
        ]), styleGroup() ]; }
    });
    // Marquee: continuously scrolling text/announcements (pure CSS).
    def('marquee', {
        label: 'Marquee', icon: 'bi-signpost-split', category: 'Sections', hot: true,
        _desc: 'A continuously scrolling row of text or announcements (pure CSS).',
        defaults: function () { return { items: [{ text: 'Free shipping over $50' }, { text: 'New arrivals every week' }, { text: '30-day returns' }], speed: 'normal', bg: '', color: '' }; },
        render: function (n) {
            var p = n.props || {};
            var one = (Array.isArray(p.items) ? p.items : []).map(function (it) { return '<span class="ppd-c-mq-item">' + icon('bi-star-fill') + esc(it.text || '') + '</span>'; }).join('');
            var sty = ''; if ((p.bg || '').trim()) sty += 'background:' + esc(p.bg) + ';'; if ((p.color || '').trim()) sty += 'color:' + esc(p.color) + ';';
            return '<div class="ppd-c-mq spd-' + (['slow', 'normal', 'fast'].indexOf(p.speed) > -1 ? p.speed : 'normal') + '"' + (sty ? ' style="' + sty + '"' : '') + '><div class="ppd-c-mq-track"><div class="ppd-c-mq-seq">' + one + '</div><div class="ppd-c-mq-seq" aria-hidden="true">' + one + '</div></div></div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'speed', label: 'Speed', type: 'select', options: [['slow', 'Slow'], ['normal', 'Normal'], ['fast', 'Fast']] },
            { key: 'bg', label: 'Background', type: 'color' },
            { key: 'color', label: 'Text colour', type: 'color' }
        ]),
        { title: 'Items', fields: [
            { key: 'items', label: 'Messages', type: 'list', itemLabel: 'Message', addLabel: 'Add message', default: { text: 'New message' }, item: [{ key: 'text', label: 'Text', type: 'text' }] }
        ]},
        styleGroup() ]; }
    });
    // Social Links: a row of social icon links.
    def('social-links', {
        label: 'Social Links', icon: 'bi-share', category: 'Sections', hot: true,
        _desc: 'A row of social-media icon links — filled, outline or plain, in any size.',
        defaults: function () { return { heading: '', items: [{ icon: 'bi-twitter-x', href: '#' }, { icon: 'bi-instagram', href: '#' }, { icon: 'bi-linkedin', href: '#' }, { icon: 'bi-youtube', href: '#' }, { icon: 'bi-github', href: '#' }], variant: 'filled', size: 'md', align: 'center' }; },
        render: function (n) {
            var p = n.props || {};
            var items = (Array.isArray(p.items) ? p.items : []).map(function (s) { return '<a class="ppd-c-soc" aria-label="social">' + icon(s.icon || 'bi-link-45deg') + '</a>'; }).join('');
            return '<section class="ppd-c-socials v-' + (['filled', 'outline', 'plain'].indexOf(p.variant) > -1 ? p.variant : 'filled') + ' s-' + (['sm', 'md', 'lg'].indexOf(p.size) > -1 ? p.size : 'md') + ' align-' + (['left', 'center', 'right'].indexOf(p.align) > -1 ? p.align : 'center') + '"><div class="ppd-c-soc-wrap">' + ((p.heading || '').trim() ? '<span class="ppd-c-soc-h">' + esc(p.heading) + '</span>' : '') + '<div class="ppd-c-soc-row">' + items + '</div></div></section>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text', placeholder: 'optional' },
            { key: 'variant', label: 'Style', type: 'select', options: [['filled', 'Filled'], ['outline', 'Outline'], ['plain', 'Plain']] },
            { key: 'size', label: 'Size', type: 'select', options: [['sm', 'Small'], ['md', 'Medium'], ['lg', 'Large']] },
            { key: 'align', label: 'Align', type: 'select', options: [['left', 'Left'], ['center', 'Center'], ['right', 'Right']] }
        ]),
        { title: 'Links', fields: [
            { key: 'items', label: 'Socials', type: 'list', itemLabel: 'Social', addLabel: 'Add social', default: { icon: 'bi-link-45deg', href: '#' }, item: [
                { key: 'icon', label: 'Icon', type: 'icon' },
                { key: 'href', label: 'URL', type: 'text' }
            ] }
        ]},
        styleGroup() ]; }
    });
    // Comparison Table: columns (plans) × rows (features) with check/cross/custom.
    def('comparison-table', {
        label: 'Comparison Table', icon: 'bi-table', category: 'Sections', hot: true,
        _desc: 'A feature comparison table — columns (plans) × rows (features) with check / cross / custom values. Each row’s values are one line per column.',
        defaults: function () { return { heading: 'Compare plans', subhead: '',
            columns: [{ name: 'Starter' }, { name: 'Pro', highlighted: true, badge: 'Popular' }, { name: 'Business' }],
            rows: [
                { feature: 'Projects', values: '1\nUnlimited\nUnlimited' },
                { feature: 'Priority support', values: 'no\nyes\nyes' },
                { feature: 'SSO & audit logs', values: 'no\nno\nyes' },
                { feature: 'SLA', values: 'no\nno\nyes' }
            ] }; },
        render: function (n) {
            var p = n.props || {}, cols = Array.isArray(p.columns) ? p.columns : [];
            var head = '<tr><th class="ppd-c-cmp-corner"></th>' + cols.map(function (c) { return '<th' + (c.highlighted ? ' class="is-hi"' : '') + '>' + esc(c.name || '') + (c.highlighted ? '<span class="ppd-c-cmp-badge">' + esc(c.badge || 'Popular') + '</span>' : '') + '</th>'; }).join('') + '</tr>';
            var body = (Array.isArray(p.rows) ? p.rows : []).map(function (r) {
                var vals = String(r.values || '').split('\n');
                var tds = cols.map(function (c, i) {
                    var v = (vals[i] || '').trim(), cell;
                    if (/^(yes|true|✓|y)$/i.test(v)) cell = '<span class="ppd-c-cmp-yes">' + icon('bi-check-lg') + '</span>';
                    else if (v === '' || /^(no|false|✗|x|n|-)$/i.test(v)) cell = '<span class="ppd-c-cmp-no">' + icon('bi-dash') + '</span>';
                    else cell = '<span class="ppd-c-cmp-txt">' + esc(v) + '</span>';
                    return '<td' + (c.highlighted ? ' class="is-hi"' : '') + '>' + cell + '</td>';
                }).join('');
                return '<tr><th scope="row">' + esc(r.feature || '') + '</th>' + tds + '</tr>';
            }).join('');
            var hd = ((p.heading || '').trim() || (p.subhead || '').trim()) ? '<div class="ppd-c-cmp-head">' + ((p.heading || '').trim() ? '<h2>' + esc(p.heading) + '</h2>' : '') + ((p.subhead || '').trim() ? '<p>' + esc(p.subhead) + '</p>' : '') + '</div>' : '';
            return '<section class="ppd-c-compare"><div class="ppd-c-cmp-wrap">' + hd + '<div class="ppd-c-cmp-scroll"><table class="ppd-c-cmp-tbl"><thead>' + head + '</thead><tbody>' + body + '</tbody></table></div></div></section>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text', placeholder: 'optional' },
            { key: 'subhead', label: 'Sub-heading', type: 'textarea', placeholder: 'optional' }
        ]),
        { title: 'Columns (plans)', fields: [
            { key: 'columns', label: 'Columns', type: 'list', itemLabel: 'Column', addLabel: 'Add column', default: { name: 'Plan', highlighted: false, badge: '' }, item: [
                { key: 'name', label: 'Name', type: 'text' },
                { key: 'highlighted', label: 'Highlight', type: 'bool' },
                { key: 'badge', label: 'Badge', type: 'text', placeholder: 'optional' }
            ] }
        ]},
        { title: 'Rows (features)', fields: [
            { key: 'rows', label: 'Rows', type: 'list', itemLabel: 'Row', addLabel: 'Add row', default: { feature: 'Feature', values: 'yes\nyes\nyes' }, item: [
                { key: 'feature', label: 'Feature', type: 'text' },
                { key: 'values', label: 'Values (one per column)', type: 'textarea', hint: 'One line per column, in order. Use yes / no / - or free text.' }
            ] }
        ]},
        styleGroup() ]; }
    });
    // Back to Top: floating button, appears after scrolling (BUILD adds the JS;
    // canvas shows a static preview via .is-preview).
    def('back-to-top', {
        label: 'Back to Top', icon: 'bi-arrow-up-circle', category: 'Sections', hot: true,
        _desc: 'A floating button that appears after scrolling and smooth-scrolls back to the top. Drop it anywhere — it floats.',
        defaults: function () { return { icon: 'bi-arrow-up', position: 'right', shape: 'circle', showAfter: 400 }; },
        render: function (n) {
            var p = n.props || {};
            return '<button type="button" class="ppd-c-totop pos-' + (p.position === 'left' ? 'left' : 'right') + ' sh-' + (p.shape === 'rounded' ? 'rounded' : 'circle') + ' is-preview" data-after="' + (parseInt(p.showAfter, 10) || 400) + '" aria-label="Back to top">' + icon(p.icon || 'bi-arrow-up') + '</button>';
        },
        schema: function () { return [ generalGroup([
            { key: 'icon', label: 'Icon', type: 'icon' },
            { key: 'position', label: 'Position', type: 'select', options: [['right', 'Bottom-right'], ['left', 'Bottom-left']] },
            { key: 'shape', label: 'Shape', type: 'select', options: [['circle', 'Circle'], ['rounded', 'Rounded']] },
            { key: 'showAfter', label: 'Show after (px)', type: 'range', min: 100, max: 1200, step: 50, suffix: 'px', def: 400, hint: 'How far the visitor scrolls before it appears.' }
        ]), styleGroup() ]; }
    });
    // Cookie Consent: bottom banner, remembered via localStorage (BUILD adds JS).
    def('cookie-consent', {
        label: 'Cookie Consent', icon: 'bi-cookie', category: 'Sections', hot: true,
        _desc: 'A cookie-consent banner with Accept / Decline, remembered in the browser (localStorage) so it only shows once.',
        defaults: function () { return { message: 'We use cookies to improve your experience.', acceptLabel: 'Accept', declineLabel: 'Decline', policyLabel: 'Privacy policy', policyHref: '#', position: 'bottom' }; },
        render: function (n) {
            var p = n.props || {}, id = 'ppck-' + (n.id || 'x');
            var policy = (p.policyLabel || '').trim() ? ' <a class="ppd-c-cookie-link">' + esc(p.policyLabel) + '</a>' : '';
            var decline = (p.declineLabel || '').trim() ? '<button type="button" class="ppd-c-cookie-btn is-ghost" data-ck="decline">' + esc(p.declineLabel) + '</button>' : '';
            return '<div class="ppd-c-cookie pos-' + (p.position === 'bottom-left' ? 'bl' : 'bottom') + ' is-preview" id="' + esc(id) + '"><div class="ppd-c-cookie-msg">' + icon('bi-cookie') + '<span>' + esc(p.message || '') + policy + '</span></div><div class="ppd-c-cookie-acts">' + decline + '<button type="button" class="ppd-c-cookie-btn is-primary" data-ck="accept">' + esc(p.acceptLabel || 'Accept') + '</button></div></div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'message', label: 'Message', type: 'textarea' },
            { key: 'policyLabel', label: 'Policy link label', type: 'text', placeholder: 'optional' },
            { key: 'policyHref', label: 'Policy URL', type: 'text' },
            { key: 'acceptLabel', label: 'Accept label', type: 'text' },
            { key: 'declineLabel', label: 'Decline label', type: 'text', placeholder: 'blank = no decline button' },
            { key: 'position', label: 'Position', type: 'select', options: [['bottom', 'Bottom (full width)'], ['bottom-left', 'Bottom-left card']] }
        ]), styleGroup() ]; }
    });
    // Newsletter: email signup. Emails you the subscriber (self-contained, no DB)
    // OR posts to your provider (Mailchimp/Formspree…) via an external action URL.
    def('newsletter', {
        label: 'Newsletter', icon: 'bi-envelope-heart', category: 'Sections', hot: true,
        _desc: 'An email signup — either emails you each new subscriber (no database) or posts to your email provider (Mailchimp, Formspree…) via an action URL.',
        defaults: function () { return { heading: 'Stay in the loop', subhead: 'Get product news and tips. No spam, unsubscribe anytime.', placeholder: 'you@example.com', buttonLabel: 'Subscribe', successMsg: 'You’re subscribed — thanks!', recipient: '', action: '', layout: 'inline' }; },
        render: function (n) {
            var p = n.props || {};
            var head = ((p.heading || '').trim() || (p.subhead || '').trim()) ? '<div class="ppd-c-news-head">' + ((p.heading || '').trim() ? '<h2>' + esc(p.heading) + '</h2>' : '') + ((p.subhead || '').trim() ? '<p>' + esc(p.subhead) + '</p>' : '') + '</div>' : '';
            var note = (!(p.recipient || '').trim() && !(p.action || '').trim()) ? '<div class="ppd-c-news-note">' + icon('bi-info-circle') + '<span>Set a recipient email (or a provider action URL) in the inspector.</span></div>' : '';
            return '<section class="ppd-c-news layout-' + (p.layout === 'stacked' ? 'stacked' : 'inline') + '"><div class="ppd-c-news-wrap">' + head + '<form class="ppd-c-news-form"><input type="email" placeholder="' + esc(p.placeholder || 'you@example.com') + '"><button type="button" class="ppd-c-news-btn">' + esc(p.buttonLabel || 'Subscribe') + '</button></form>' + note + '</div></section>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text' },
            { key: 'subhead', label: 'Sub-heading', type: 'textarea' },
            { key: 'placeholder', label: 'Field placeholder', type: 'text' },
            { key: 'buttonLabel', label: 'Button label', type: 'text' },
            { key: 'layout', label: 'Layout', type: 'select', options: [['inline', 'Inline (field + button)'], ['stacked', 'Stacked']] }
        ]),
        { title: 'Delivery', fields: [
            { key: 'recipient', label: 'Email subscribers to', type: 'text', placeholder: 'you@example.com', hint: 'Self-contained: each signup emails you via the host mailer (no database). Kept server-side.' },
            { key: 'action', label: 'Or provider action URL', type: 'text', placeholder: 'https://… (Mailchimp / Formspree)', hint: 'If set, the form posts here instead — the field is named “email”.' },
            { key: 'successMsg', label: 'Success message', type: 'textarea', hint: 'Shown after a self-contained signup.' }
        ]},
        styleGroup() ]; }
    });
    // Feature Split: alternating media + text rows (the classic "feature story").
    def('feature-split', {
        label: 'Feature Split', icon: 'bi-layout-split', category: 'Sections', hot: true,
        _desc: 'Alternating media + text rows — image on one side, headline / description / CTA on the other. The classic “feature story” section.',
        defaults: function () { return { heading: '', subhead: '',
            rows: [
                { image: '', eyebrow: 'Fast', title: 'Ship in minutes, not months', text: 'Design visually and build to fast, database-free PHP — no code required.', ctaLabel: 'Learn more', ctaHref: '#', side: 'auto' },
                { image: '', eyebrow: 'Flexible', title: 'Every block you need', text: 'A full library of responsive, themeable components that inherit your brand.', ctaLabel: '', ctaHref: '#', side: 'auto' }
            ] }; },
        render: function (n) {
            var p = n.props || {};
            var head = ((p.heading || '').trim() || (p.subhead || '').trim()) ? '<div class="ppd-c-fs-head">' + ((p.heading || '').trim() ? '<h2>' + esc(p.heading) + '</h2>' : '') + ((p.subhead || '').trim() ? '<p>' + esc(p.subhead) + '</p>' : '') + '</div>' : '';
            var rows = (Array.isArray(p.rows) ? p.rows : []).map(function (r, i) {
                var side = r.side === 'left' ? 'left' : (r.side === 'right' ? 'right' : (i % 2 === 0 ? 'left' : 'right'));
                var media = (r.image || '').trim() ? '<img src="' + esc(r.image) + '" alt="">' : '<div class="ppd-c-fs-ph">' + icon('bi-image') + '</div>';
                var cta = (r.ctaLabel || '').trim() ? '<a class="ppd-c-fs-cta">' + esc(r.ctaLabel) + icon('bi-arrow-right') + '</a>' : '';
                return '<div class="ppd-c-fs-row media-' + side + '"><div class="ppd-c-fs-media">' + media + '</div><div class="ppd-c-fs-text">'
                    + ((r.eyebrow || '').trim() ? '<span class="ppd-c-fs-eyebrow">' + esc(r.eyebrow) + '</span>' : '')
                    + '<h3>' + esc(r.title || '') + '</h3>' + ((r.text || '').trim() ? '<p>' + esc(r.text) + '</p>' : '') + cta + '</div></div>';
            }).join('');
            return '<section class="ppd-c-fsplit"><div class="ppd-c-fs-wrap">' + head + rows + '</div></section>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Section heading', type: 'text', placeholder: 'optional' },
            { key: 'subhead', label: 'Sub-heading', type: 'textarea', placeholder: 'optional' }
        ]),
        { title: 'Rows', fields: [
            { key: 'rows', label: 'Rows', type: 'list', itemLabel: 'Row', addLabel: 'Add row', default: { image: '', eyebrow: '', title: 'New feature', text: 'Describe it.', ctaLabel: '', ctaHref: '#', side: 'auto' }, item: [
                { key: 'image', label: 'Image URL', type: 'text' },
                { key: 'eyebrow', label: 'Eyebrow', type: 'text', placeholder: 'optional' },
                { key: 'title', label: 'Title', type: 'text' },
                { key: 'text', label: 'Text', type: 'textarea' },
                { key: 'ctaLabel', label: 'Link label', type: 'text', placeholder: 'optional' },
                { key: 'ctaHref', label: 'Link URL', type: 'text' },
                { key: 'side', label: 'Image side', type: 'select', options: [['auto', 'Auto (alternate)'], ['left', 'Left'], ['right', 'Right']] }
            ] }
        ]},
        styleGroup() ]; }
    });
    // Bento Grid: modern asymmetric tile grid (Apple/Linear style).
    def('bento-grid', {
        label: 'Bento Grid', icon: 'bi-grid-3x3', category: 'Sections', hot: true,
        _desc: 'A modern asymmetric “bento” grid of feature tiles in mixed sizes — the trendy Apple/Linear-style showcase.',
        defaults: function () { return { heading: '', subhead: '',
            items: [
                { icon: 'bi-lightning-charge', title: 'Blazing fast', text: 'Static-fast pages with zero database overhead.', size: 'wide', accent: '', image: '' },
                { icon: 'bi-brush', title: 'Design visually', text: '', size: 'md', accent: '', image: '' },
                { icon: 'bi-shield-check', title: 'Secure', text: '', size: 'md', accent: '', image: '' },
                { icon: 'bi-magic', title: 'Themeable', text: 'Every token, your brand.', size: 'tall', accent: '', image: '' },
                { icon: 'bi-plug', title: 'Integrates', text: '', size: 'md', accent: '', image: '' },
                { icon: 'bi-graph-up-arrow', title: 'Analytics', text: '', size: 'md', accent: '', image: '' }
            ] }; },
        render: function (n) {
            var p = n.props || {};
            var head = ((p.heading || '').trim() || (p.subhead || '').trim()) ? '<div class="ppd-c-bento-head">' + ((p.heading || '').trim() ? '<h2>' + esc(p.heading) + '</h2>' : '') + ((p.subhead || '').trim() ? '<p>' + esc(p.subhead) + '</p>' : '') + '</div>' : '';
            var items = (Array.isArray(p.items) ? p.items : []).map(function (it) {
                var sz = ['md', 'wide', 'tall', 'big'].indexOf(it.size) > -1 ? it.size : 'md';
                var sty = (it.accent || '').trim() ? ' style="--bento-accent:' + esc(it.accent) + '"' : '';
                var img = (it.image || '').trim() ? '<img class="ppd-c-bento-img" src="' + esc(it.image) + '" alt="">' : '';
                return '<div class="ppd-c-bento sz-' + sz + ((it.image || '').trim() ? ' has-img' : '') + '"' + sty + '>' + img + '<div class="ppd-c-bento-ic">' + icon(it.icon || 'bi-star') + '</div><h3>' + esc(it.title || '') + '</h3>' + ((it.text || '').trim() ? '<p>' + esc(it.text) + '</p>' : '') + '</div>';
            }).join('');
            return '<section class="ppd-c-bentos"><div class="ppd-c-bento-wrap">' + head + '<div class="ppd-c-bento-grid">' + items + '</div></div></section>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Section heading', type: 'text', placeholder: 'optional' },
            { key: 'subhead', label: 'Sub-heading', type: 'textarea', placeholder: 'optional' }
        ]),
        { title: 'Tiles', fields: [
            { key: 'items', label: 'Tiles', type: 'list', itemLabel: 'Tile', addLabel: 'Add tile', default: { icon: 'bi-star', title: 'New tile', text: '', size: 'md', accent: '', image: '' }, item: [
                { key: 'icon', label: 'Icon', type: 'icon' },
                { key: 'title', label: 'Title', type: 'text' },
                { key: 'text', label: 'Text', type: 'textarea', placeholder: 'optional' },
                { key: 'size', label: 'Size', type: 'select', options: [['md', 'Small (1×1)'], ['wide', 'Wide (2×1)'], ['tall', 'Tall (1×2)'], ['big', 'Big (2×2)']] },
                { key: 'accent', label: 'Accent colour', type: 'color', placeholder: 'optional' },
                { key: 'image', label: 'Background image URL', type: 'text', placeholder: 'optional' }
            ] }
        ]},
        styleGroup() ]; }
    });
    // Video: paste a YouTube/Vimeo link or a direct MP4/WebM URL. Canvas shows a
    // labelled placeholder; the BUILD renders the real responsive embed.
    function videoParse(url) {
        url = String(url || '').trim();
        var yt = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([A-Za-z0-9_-]{6,})/);
        if (yt) return { type: 'yt', id: yt[1] };
        var vm = url.match(/vimeo\.com\/(?:video\/)?(\d+)/);
        if (vm) return { type: 'vm', id: vm[1] };
        if (/\.(mp4|webm|ogg)(\?|#|$)/i.test(url)) return { type: 'file', url: url };
        if (url) return { type: 'iframe', url: url };
        return { type: 'none' };
    }
    def('video-embed', {
        label: 'Video', icon: 'bi-play-btn', category: 'Sections', hot: true,
        _desc: 'A responsive video — paste a YouTube or Vimeo link, or a direct MP4/WebM URL. Optional autoplay, loop, mute, poster and caption.',
        defaults: function () { return { url: '', aspect: '16/9', maxWidth: '', rounded: true, shadow: true, caption: '', autoplay: false, muted: true, loop: false, controls: true, poster: '' }; },
        render: function (n) {
            var p = n.props || {}, v = videoParse(p.url);
            var label = v.type === 'yt' ? 'YouTube video' : v.type === 'vm' ? 'Vimeo video' : v.type === 'file' ? 'Video file' : v.type === 'iframe' ? 'Embedded video' : 'No video';
            var box = '<div class="ppd-c-video-ph"><span class="ppd-c-video-play">' + icon('bi-play-fill') + '</span><span class="ppd-c-video-lbl">' + esc(label) + (v.type === 'none' ? ' — paste a URL' : '') + '</span></div>';
            var sty = ''; if ((p.maxWidth || '').trim()) sty += 'max-width:' + esc(p.maxWidth) + ';margin-left:auto;margin-right:auto;';
            return '<figure class="ppd-c-video' + (p.rounded !== false ? ' is-rounded' : '') + (p.shadow !== false ? ' is-shadow' : '') + '"' + (sty ? ' style="' + sty + '"' : '') + '><div class="ppd-c-video-frame" style="aspect-ratio:' + esc(p.aspect || '16/9') + '">' + box + '</div>' + ((p.caption || '').trim() ? '<figcaption>' + esc(p.caption) + '</figcaption>' : '') + '</figure>';
        },
        schema: function () { return [ generalGroup([
            { key: 'url', label: 'Video URL', type: 'text', placeholder: 'YouTube / Vimeo link, or …/clip.mp4', hint: 'Paste a YouTube or Vimeo page URL, or a direct MP4/WebM file URL.' },
            { key: 'aspect', label: 'Aspect ratio', type: 'select', options: ['16/9', '4/3', '1/1', '21/9', '9/16'] },
            { key: 'maxWidth', label: 'Max width', type: 'text', placeholder: 'e.g. 800px (blank = full)' },
            { key: 'caption', label: 'Caption', type: 'text', placeholder: 'optional' }
        ]),
        { title: 'Playback', fields: [
            { key: 'autoplay', label: 'Autoplay', type: 'bool', hint: 'Browsers require muted for autoplay.' },
            { key: 'muted', label: 'Muted', type: 'bool' },
            { key: 'loop', label: 'Loop', type: 'bool' },
            { key: 'controls', label: 'Show controls (MP4)', type: 'bool' },
            { key: 'poster', label: 'Poster image (MP4)', type: 'text', placeholder: 'optional' }
        ]},
        { title: 'Appearance', fields: [
            { key: 'rounded', label: 'Rounded corners', type: 'bool' },
            { key: 'shadow', label: 'Shadow', type: 'bool' }
        ]},
        styleGroup() ]; }
    });
    // Post Grid: blog/news cards. Manual entries, OR live rows from a table.
    def('post-grid', {
        label: 'Post Grid', icon: 'bi-postcard', category: 'Sections', hot: true,
        _desc: 'A blog/news card grid — image, category, title, excerpt, author and date, with a read-more link. Manual entries, or pull live rows from a table.',
        defaults: function () { return { heading: 'From the blog', subhead: '', columns: '3', linkView: true, orderDir: 'DESC', limit: 6,
            items: [
                { image: '', category: 'Product', title: 'Introducing standalone sites', excerpt: 'Build fast, database-free pages visually.', author: 'Jane Doe', date: 'Aug 10, 2026', href: '#' },
                { image: '', category: 'Guide', title: 'Design a landing page in 10 minutes', excerpt: 'A quick step-by-step walkthrough.', author: 'John Smith', date: 'Aug 6, 2026', href: '#' },
                { image: '', category: 'News', title: 'What’s new in v2', excerpt: 'New components, themes and more.', author: 'Amy Lee', date: 'Aug 1, 2026', href: '#' }
            ] }; },
        render: function (n) {
            var p = n.props || {}, cols = ['2', '3'].indexOf(String(p.columns)) > -1 ? String(p.columns) : '3', dyn = !!p.table;
            var badge = dyn ? ' <i class="bi bi-database ppd-dynbadge" title="Live from ' + esc(title(p.table)) + '"></i>' : '';
            var head = ((p.heading || '').trim() || (p.subhead || '').trim() || dyn) ? '<div class="ppd-c-post-head">' + ((p.heading || '').trim() || dyn ? '<h2>' + esc(p.heading || '') + badge + '</h2>' : '') + ((p.subhead || '').trim() ? '<p>' + esc(p.subhead) + '</p>' : '') + '</div>' : '';
            // In the canvas a bound grid shows placeholder cards (real rows load at runtime).
            var srcItems = dyn
                ? [0, 1, 2].map(function () { return { image: '', category: '', title: 'Live post', excerpt: 'Pulled from ' + title(p.table) + ' at runtime.', author: '', date: '' }; })
                : (Array.isArray(p.items) ? p.items : []);
            var items = srcItems.map(function (it) {
                var media = (it.image || '').trim() ? '<img src="' + esc(it.image) + '" alt="">' : '<div class="ppd-c-post-ph">' + icon('bi-image') + '</div>';
                var meta = [(it.author || '').trim(), (it.date || '').trim()].filter(Boolean).map(esc).join(' · ');
                return '<article class="ppd-c-post"><div class="ppd-c-post-media">' + media + '</div><div class="ppd-c-post-body">'
                    + ((it.category || '').trim() ? '<span class="ppd-c-post-cat">' + esc(it.category) + '</span>' : '')
                    + '<h3>' + esc(it.title || '') + '</h3>' + ((it.excerpt || '').trim() ? '<p>' + esc(it.excerpt) + '</p>' : '')
                    + (meta ? '<div class="ppd-c-post-meta">' + meta + '</div>' : '') + '<span class="ppd-c-post-more">Read more' + icon('bi-arrow-right') + '</span></div></article>';
            }).join('');
            return '<section class="ppd-c-posts"><div class="ppd-c-post-wrap">' + head + '<div class="ppd-c-post-grid cols-' + cols + '">' + items + '</div></div></section>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text', placeholder: 'optional' },
            { key: 'subhead', label: 'Sub-heading', type: 'textarea', placeholder: 'optional' },
            { key: 'columns', label: 'Columns', type: 'select', options: [['2', '2'], ['3', '3']] }
        ]),
        { title: 'Live data (optional)', fields: [
            { key: 'table', label: 'Posts from table', type: 'dbtable', rerender: true, hint: 'Optional — pull one card per row from a table instead of the manual list below.' },
            { key: 'titleColumn', label: 'Title column', type: 'dbcolumn', tableKey: 'table', hint: 'Column shown as each card’s title.' },
            { key: 'excerptColumn', label: 'Excerpt column', type: 'dbcolumn', tableKey: 'table', hint: 'Optional — short summary text.' },
            { key: 'imageColumn', label: 'Image URL column', type: 'dbcolumn', tableKey: 'table', hint: 'Optional — column holding a cover image URL.' },
            { key: 'categoryColumn', label: 'Category column', type: 'dbcolumn', tableKey: 'table', hint: 'Optional — the little tag above the title.' },
            { key: 'authorColumn', label: 'Author column', type: 'dbcolumn', tableKey: 'table', hint: 'Optional — shown in the meta line.' },
            { key: 'dateColumn', label: 'Date column', type: 'dbcolumn', tableKey: 'table', hint: 'Optional — shown in the meta line.' },
            { key: 'where', label: 'Filter (WHERE)', type: 'text', full: true, placeholder: "status = 'published'", hint: 'Optional SQL condition — supports SQL variables (:userid, :today…).' },
            { key: 'orderColumn', label: 'Order by column', type: 'dbcolumn', tableKey: 'table', hint: 'Column to sort by (blank = natural order).' },
            { key: 'orderDir', label: 'Order', type: 'select', options: ['DESC', 'ASC'], hint: 'DESC = newest first · ASC = oldest first.' },
            { key: 'limit', label: 'Max posts', type: 'number', hint: 'How many rows to show (1–30).' },
            { key: 'linkView', label: 'Link to record page', type: 'bool', boolLabel: 'open the record’s view page', hint: 'Each card links to that record’s generated view page.' },
            { key: 'idColumn', label: 'ID column', type: 'dbcolumn', tableKey: 'table', hint: 'Primary key used in the view-page link (default: id).' },
            { key: 'linkColumn', label: 'Link URL column', type: 'dbcolumn', tableKey: 'table', hint: 'Optional — a column holding a full URL; overrides the record-page link.' }
        ]},
        { title: 'Posts (manual)', fields: [
            { key: 'items', label: 'Posts', type: 'list', itemLabel: 'Post', addLabel: 'Add post', default: { image: '', category: '', title: 'New post', excerpt: '', author: '', date: '', href: '#' }, item: [
                { key: 'image', label: 'Image URL', type: 'text' },
                { key: 'category', label: 'Category', type: 'text', placeholder: 'optional' },
                { key: 'title', label: 'Title', type: 'text' },
                { key: 'excerpt', label: 'Excerpt', type: 'textarea' },
                { key: 'author', label: 'Author', type: 'text', placeholder: 'optional' },
                { key: 'date', label: 'Date', type: 'text', placeholder: 'optional' },
                { key: 'href', label: 'Link URL', type: 'text' }
            ] }
        ]},
        styleGroup() ]; }
    });
    // Checklist: benefits list with check/cross, one or two columns.
    def('checklist', {
        label: 'Checklist', icon: 'bi-check2-square', category: 'Sections', hot: true,
        _desc: 'A benefits checklist — check ✓ or cross ✗ per item, in one or two columns. Great for “what’s included”.',
        defaults: function () { return { heading: '', subhead: '', columns: '1',
            items: [
                { text: 'Unlimited projects', included: true }, { text: 'Priority support', included: true },
                { text: 'Custom domain', included: true }, { text: 'White-label (Business only)', included: false }
            ] }; },
        render: function (n) {
            var p = n.props || {}, cols = String(p.columns) === '2' ? '2' : '1';
            var head = ((p.heading || '').trim() || (p.subhead || '').trim()) ? '<div class="ppd-c-check-head">' + ((p.heading || '').trim() ? '<h2>' + esc(p.heading) + '</h2>' : '') + ((p.subhead || '').trim() ? '<p>' + esc(p.subhead) + '</p>' : '') + '</div>' : '';
            var items = (Array.isArray(p.items) ? p.items : []).map(function (it) {
                var inc = it.included !== false;
                return '<li class="' + (inc ? 'is-yes' : 'is-no') + '">' + icon(inc ? 'bi-check-circle-fill' : 'bi-x-circle') + '<span>' + esc(it.text || '') + '</span></li>';
            }).join('');
            return '<section class="ppd-c-checklist"><div class="ppd-c-check-wrap">' + head + '<ul class="ppd-c-check-list cols-' + cols + '">' + items + '</ul></div></section>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text', placeholder: 'optional' },
            { key: 'subhead', label: 'Sub-heading', type: 'textarea', placeholder: 'optional' },
            { key: 'columns', label: 'Columns', type: 'select', options: [['1', '1'], ['2', '2']] }
        ]),
        { title: 'Items', fields: [
            { key: 'items', label: 'Items', type: 'list', itemLabel: 'Item', addLabel: 'Add item', default: { text: 'New item', included: true }, item: [
                { key: 'text', label: 'Text', type: 'text' },
                { key: 'included', label: 'Included (✓)', type: 'bool' }
            ] }
        ]},
        styleGroup() ]; }
    });
    // Link List: a stack of full-width link buttons (link-in-bio, resource lists).
    def('link-list', {
        label: 'Link List', icon: 'bi-list-ul', category: 'Sections', hot: true,
        _desc: 'A stack of full-width link buttons — perfect for a “link in bio” page or a list of resources.',
        defaults: function () { return { variant: 'solid', maxWidth: '460px',
            items: [
                { label: 'My website', href: '#', icon: 'bi-globe' },
                { label: 'Latest video', href: '#', icon: 'bi-youtube' },
                { label: 'Newsletter', href: '#', icon: 'bi-envelope' },
                { label: 'Say hello', href: '#', icon: 'bi-chat-dots' }
            ] }; },
        render: function (n) {
            var p = n.props || {};
            var v = ['solid', 'outline', 'soft'].indexOf(p.variant) > -1 ? p.variant : 'solid';
            var mw = (p.maxWidth || '').trim() ? 'max-width:' + esc(p.maxWidth) + ';' : '';
            var items = (Array.isArray(p.items) ? p.items : []).map(function (l) { return '<a class="ppd-c-linkitem is-' + v + '">' + (l.icon ? icon(l.icon) : '') + '<span>' + esc(l.label || 'Link') + '</span></a>'; }).join('');
            return '<div class="ppd-c-linklist"' + (mw ? ' style="' + mw + '"' : '') + '>' + items + '</div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'variant', label: 'Button style', type: 'select', options: [['solid', 'Solid'], ['outline', 'Outline'], ['soft', 'Soft']] },
            { key: 'maxWidth', label: 'Max width', type: 'text', placeholder: 'e.g. 460px' }
        ]),
        { title: 'Links', fields: [
            { key: 'items', label: 'Links', type: 'list', itemLabel: 'Link', addLabel: 'Add link', default: { label: 'New link', href: '#', icon: 'bi-link-45deg' }, item: [
                { key: 'label', label: 'Label', type: 'text' },
                { key: 'href', label: 'URL', type: 'text' },
                { key: 'icon', label: 'Icon', type: 'icon' }
            ] }
        ]},
        styleGroup() ]; }
    });
    def('image', {
        label: 'Image', icon: 'bi-image', category: 'Content',
        _desc: 'A rich image: URL or SVG, resize live, fit/crop, focal point, transparent or coloured backdrop, radius, border, shadow, opacity, grayscale/blur filters, hover effects and an optional link.',
        defaults: function () { return { src: '', alt: 'Image', ratio: 'auto', fit: 'contain', position: 'center',
            width: 100, align: 'center', bg: '', radius: 0, pad: 0, border: false, borderColor: '', borderWidth: 1,
            shadow: 'none', opacity: 100, grayscale: false, blur: 0, hover: 'none', link: '', newTab: true }; },
        render: function (n) {
            var p = n.props || {};
            var inner = p.src
                ? '<img class="ppd-c-image" src="' + esc(p.src) + '" alt="' + esc(p.alt || '') + '" loading="lazy" decoding="async"' + (imgImgCss(p) ? ' style="' + imgImgCss(p) + '"' : '') + '>'
                : '<div class="ppd-c-image placeholder" style="aspect-ratio:' + esc(p.ratio && p.ratio !== 'auto' ? p.ratio : '16/9') + '">' + icon('bi-image') + '<small>' + esc(p.alt || 'Image') + '</small></div>';
            var box = '<div class="ppd-c-imgbox' + imgHoverCls(p) + '" style="' + imgBoxCss(p) + '">' + inner + '</div>';
            // Canvas: render the link wrapper WITHOUT href so a click selects instead
            // of navigating (the build adds the real href). Mirrors external-link.
            if ((p.link || '').trim()) box = '<a class="ppd-c-imglink">' + box + '</a>';
            return box;
        },
        schema: function () { return [ generalGroup([
            { key: 'src', label: 'Source URL', type: 'text', placeholder: 'https://… .jpg / .png / .svg / .webp', hint: 'Direct URL to the image. SVG, PNG (with transparency), JPG and WebP all work.' },
            { key: 'alt', label: 'Alt text', type: 'text', hint: 'Describes the image for screen readers and when it fails to load. Good for SEO.' },
            { key: 'link', label: 'Link URL', type: 'text', placeholder: 'https://… (optional)', hint: 'Make the image clickable — leave blank for a plain image.' },
            { key: 'newTab', label: 'Open link in new tab', type: 'bool', when: function (p) { return !!(p.link || '').trim(); } }
        ]),
        { title: 'Size & fit', fields: [
            { key: 'width', label: 'Size', type: 'range', min: 5, max: 100, step: 1, suffix: '%', def: 100, hint: 'Drag to resize the image in real time (percentage of its cell).' },
            { key: 'align', label: 'Align', type: 'select', options: [['left', 'Left'], ['center', 'Center'], ['right', 'Right']], hint: 'Where the image sits when it is narrower than its cell.' },
            { key: 'ratio', label: 'Aspect ratio', type: 'select', options: [['auto', 'Auto (natural)'], '16/9', '4/3', '3/2', '1/1', '3/4', '9/16'], hint: 'Auto keeps the image’s own proportions (best for logos/SVG). A fixed ratio crops/fits it into that shape.' },
            { key: 'fit', label: 'Fit', type: 'select', options: [['contain', 'Contain (show all)'], ['cover', 'Cover (fill + crop)'], ['fill', 'Stretch'], ['scale-down', 'Scale down'], ['none', 'None']], hint: 'How the image fills a fixed ratio. Contain shows the whole image (no crop); Cover fills and crops.' },
            { key: 'position', label: 'Focal point', type: 'select', options: [['center', 'Center'], ['top', 'Top'], ['bottom', 'Bottom'], ['left', 'Left'], ['right', 'Right']], when: function (p) { return (p.fit || 'contain') === 'cover'; }, hint: 'Which part stays in view when Cover crops the image.' }
        ]},
        { title: 'Appearance', fields: [
            { key: 'bg', label: 'Background', type: 'color', hint: 'Blank = transparent (best for SVG/PNG logos). Set a colour to sit the image on a solid backdrop.' },
            { key: 'radius', label: 'Corner radius', type: 'range', min: 0, max: 200, step: 1, suffix: 'px', def: 0, hint: 'Round the corners. Push it high on a square image for a circle/avatar.' },
            { key: 'pad', label: 'Inner padding', type: 'range', min: 0, max: 80, step: 1, suffix: 'px', def: 0, hint: 'Space between the image and the edge of its (optionally coloured) box.' },
            { key: 'border', label: 'Border', type: 'bool', rerender: true },
            { key: 'borderColor', label: 'Border colour', type: 'color', when: function (p) { return !!p.border; } },
            { key: 'borderWidth', label: 'Border width', type: 'range', min: 1, max: 20, step: 1, suffix: 'px', def: 1, when: function (p) { return !!p.border; } },
            { key: 'shadow', label: 'Shadow', type: 'select', options: [['none', 'None'], ['sm', 'Small'], ['md', 'Medium'], ['lg', 'Large']] },
            { key: 'opacity', label: 'Opacity', type: 'range', min: 0, max: 100, step: 1, suffix: '%', def: 100 },
            { key: 'grayscale', label: 'Grayscale', type: 'bool' },
            { key: 'blur', label: 'Blur', type: 'range', min: 0, max: 30, step: 1, suffix: 'px', def: 0 },
            { key: 'hover', label: 'Hover effect', type: 'select', options: [['none', 'None'], ['zoom', 'Zoom'], ['lift', 'Lift'], ['glow', 'Glow'], ['tilt', 'Tilt']], hint: 'A subtle animation when a visitor hovers the image.' }
        ]},
        styleGroup() ]; }
    });
    def('clock', {
        label: 'Clock', icon: 'bi-clock', category: 'Content',
        _desc: 'A live analog clock — float it on top of other components, put an image or text inside the dial, size everything, colour hands/ticks/face, toggle each hand, and set per-time-of-day symbols.',
        defaults: function () { return { size: 220, align: 'center', transparent: false,
            overlay: false, overlap: 0, offsetX: 0,
            centerType: 'image', image: '', text: '', centerSize: 48,
            centerBorder: true, textColor: '',
            showEmoji: true, showDay: true, showDate: true,
            showHour: true, showMin: true, showSec: true,
            emMorning: '🌅', emAfternoon: '☀️', emEvening: '🌇', emNight: '🌙',
            faceColor: '', faceColor2: '', faceStyle: 'solid',
            hourColor: '', minColor: '', secColor: '', tickColor: '' }; },
        render: function (n) {
            var p = n.props || {};
            // A time-of-day value may be an emoji/symbol, a "bi-*" icon, or an image URL.
            var faceBg = function () {
                var c1 = (p.faceColor || '').trim(); if (!c1) return '';
                var c2 = (p.faceColor2 || '').trim() || c1, s = p.faceStyle || 'solid';
                if (s === 'linear') return 'linear-gradient(160deg,' + c1 + ',' + c2 + ')';
                if (s === 'radial') return 'radial-gradient(circle at 50% 35%,' + c1 + ',' + c2 + ')';
                return c1;
            };
            var emHtml = function (v) { v = (v == null ? '' : String(v)).trim(); if (!v) return '';
                if (/^https?:\/\//i.test(v)) return '<img src="' + esc(v) + '" alt="">';
                if (/^bi-[a-z0-9-]+$/i.test(v)) return '<i class="bi ' + esc(v) + '"></i>';
                return esc(v); };
            var num = function (v, dflt) { var x = parseInt(v, 10); return isNaN(x) ? dflt : x; };
            var size = Math.max(60, Math.min(600, num(p.size, 220)));
            var align = ['left', 'center', 'right'].indexOf(p.align) !== -1 ? p.align : 'center';
            // Centre content: image OR text, sized by ONE slider (falls back to the
            // legacy imageSize so older clocks keep their size).
            var centerSize = Math.max(0, Math.min(95, p.centerSize != null ? num(p.centerSize, 48) : num(p.imageSize, 48)));
            var centerType = p.centerType || (p.image ? 'image' : 'none');
            var sty = '--clk:' + size + 'px;--clk-center:' + centerSize + 'cqmin;';
            if (p.hourColor) sty += '--clk-h:' + esc(p.hourColor) + ';';
            if (p.minColor) sty += '--clk-m:' + esc(p.minColor) + ';';
            if (p.secColor) sty += '--clk-s:' + esc(p.secColor) + ';';
            if (p.tickColor) sty += '--clk-tick:' + esc(p.tickColor) + ';';
            if (p.textColor) sty += '--clk-text:' + esc(p.textColor) + ';';
            var fbg = faceBg(); if (fbg) sty += '--clk-face-bg:' + esc(fbg) + ';';
            var overlay = p.overlay === true || p.overlay === 'yes';
            if (overlay) sty += '--clk-overlap:' + Math.max(0, num(p.overlap, 0)) + 'px;--clk-ox:' + num(p.offsetX, 0) + 'px;';
            var cls = 'pp-clock-widget ppc-al-' + align + (overlay ? ' is-overlay' : '')
                + (p.showHour === false ? ' no-h' : '') + (p.showMin === false ? ' no-m' : '') + (p.showSec === false ? ' no-s' : '');
            var ticks = '';
            for (var t = 0; t < 12; t++) ticks += '<span class="ppc-tick' + (t % 3 === 0 ? ' major' : '') + '" style="--t:' + (t * 30) + 'deg"></span>';
            // Preview positions the hands at the current time (the canvas has no
            // runtime script); the generated app re-syncs to the viewer's clock.
            var d = new Date();
            var secs = d.getSeconds(), mins = d.getMinutes() * 60 + secs, hrs = (d.getHours() % 12) * 3600 + mins;
            var eh = d.getHours();
            var emVal = eh >= 5 && eh < 12 ? (p.emMorning != null ? p.emMorning : '🌅')
                : eh >= 12 && eh < 17 ? (p.emAfternoon != null ? p.emAfternoon : '☀️')
                : eh >= 17 && eh < 20 ? (p.emEvening != null ? p.emEvening : '🌇')
                : (p.emNight != null ? p.emNight : '🌙');
            var dn = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getDay()];
            var transp = p.transparent === true || p.transparent === 'yes';
            var center = '';
            var noBd = p.centerBorder === false || p.centerBorder === 'no';
            if (centerType === 'image' && p.image) center = '<img class="ppc-center ppc-center-img' + (noBd ? ' no-bd' : '') + '" src="' + esc(p.image) + '" alt="">';
            else if (centerType === 'text' && (p.text || '').trim()) center = '<span class="ppc-center ppc-center-text">' + esc(p.text) + '</span>';
            var inner = ticks + center
                + (p.showEmoji !== false ? '<span class="ppc-emoji">' + emHtml(emVal) + '</span>' : '')
                + (p.showDay !== false ? '<span class="ppc-chip ppc-day">' + dn + '</span>' : '')
                + (p.showDate !== false ? '<span class="ppc-chip ppc-date">' + d.getDate() + '</span>' : '')
                + '<span class="ppc-hand h"></span><span class="ppc-hand m"></span><span class="ppc-hand s"></span>'
                + '<span class="ppc-pin"></span>';
            return '<div class="' + cls + '" style="' + sty + '">'
                + '<div class="ppc-clk' + (transp ? ' is-transparent' : '') + '" style="--sd:' + (-secs) + 's;--md:' + (-mins) + 's;--hd:' + (-hrs) + 's">'
                + '<div class="ppc-face">' + inner + '</div></div></div>';
        },
        schema: function () { return [
            generalGroup([
                { key: 'showDay', label: 'Show day', type: 'bool' },
                { key: 'showDate', label: 'Show date', type: 'bool' },
                { key: 'showEmoji', label: 'Show time-of-day symbol', type: 'bool' }
            ]),
            { title: 'Centre of dial', fields: [
                { key: 'centerType', label: 'Content', type: 'select', options: ['none', 'image', 'text'], rerender: true },
                { key: 'image', label: 'Image URL', type: 'text', placeholder: 'https://…', when: { key: 'centerType', eq: 'image' } },
                { key: 'centerBorder', label: 'Image border', type: 'bool', def: true, when: { key: 'centerType', eq: 'image' } },
                { key: 'text', label: 'Text', type: 'text', placeholder: 'e.g. PP', when: { key: 'centerType', eq: 'text' } },
                { key: 'textColor', label: 'Text colour', type: 'color', when: { key: 'centerType', eq: 'text' } },
                { key: 'centerSize', label: 'Content size', type: 'range', min: 0, max: 95, step: 2, suffix: '%', def: 48 }
            ]},
            { title: 'Placement', fields: [
                { key: 'size', label: 'Clock size (px)', type: 'number' },
                { key: 'align', label: 'Alignment', type: 'select', options: ['left', 'center', 'right'] },
                { key: 'transparent', label: 'Transparent dial (no card)', type: 'bool' },
                { key: 'overlay', label: 'Float on top of components above', type: 'bool' },
                { key: 'overlap', label: 'Overlap upward', type: 'range', min: 0, max: 500, step: 5, suffix: 'px', def: 0 },
                { key: 'offsetX', label: 'Nudge horizontally', type: 'range', min: -300, max: 300, step: 5, suffix: 'px', def: 0 }
            ]},
            { title: 'Hands', fields: [
                { key: 'showHour', label: 'Hour hand', type: 'bool' },
                { key: 'showMin', label: 'Minute hand', type: 'bool' },
                { key: 'showSec', label: 'Second hand', type: 'bool' },
                { key: 'hourColor', label: 'Hour colour', type: 'color' },
                { key: 'minColor', label: 'Minute colour', type: 'color' },
                { key: 'secColor', label: 'Second colour', type: 'color' },
                { key: 'tickColor', label: 'Hour bars', type: 'color' }
            ]},
            { title: 'Dial face', fields: [
                { key: 'faceColor', label: 'Face colour', type: 'color' },
                { key: 'faceStyle', label: 'Fill', type: 'select', options: ['solid', 'linear', 'radial'] },
                { key: 'faceColor2', label: 'Gradient · 2nd colour', type: 'color', when: 'faceColor' }
            ]},
            { title: 'Time-of-day symbol', fields: [
                { key: 'emMorning', label: 'Morning · 5–12', type: 'text', placeholder: '🌅 · bi-sunrise · https://…' },
                { key: 'emAfternoon', label: 'Afternoon · 12–17', type: 'text', placeholder: '☀️ · bi-sun · https://…' },
                { key: 'emEvening', label: 'Evening · 17–20', type: 'text', placeholder: '🌇 · bi-sunset · https://…' },
                { key: 'emNight', label: 'Night · 20–5', type: 'text', placeholder: '🌙 · bi-moon · https://…' }
            ]},
            styleGroup()
        ]; }
    });
    def('tabs', {
        label: 'Tabs', icon: 'bi-segmented-nav', category: 'Layout', container: true,
        defaults: function () { return { tabs: ['Tab 1', 'Tab 2'], barStyle: 'underline', align: 'start', size: 'md', vertical: false, activeTab: 0 }; },
        render: function (n, ctx) {
            // Per-tab droppable panes (WYSIWYG — matches the generated app).
            var p = n.props || {};
            var cls = 'ppd-c-tabs bar-' + esc(p.barStyle || 'underline') + ' align-' + esc(p.align || 'start') + ' size-' + esc(p.size || 'md') + (p.vertical ? ' is-vertical' : '');
            var sv = '';
            if (p.barBg) sv += '--pptab-barbg:' + esc(p.barBg) + ';';
            if (p.activeColor) sv += '--pptab-active:' + esc(p.activeColor) + ';';
            return '<div class="' + cls + '"' + (sv ? ' style="' + sv + '"' : '') + '>' + ctx.renderTabbed(n) + '</div>';
        },
        schema: function () { return [ generalGroup([]),
            { title: 'Tabs', fields: [{ key: 'tabs', type: 'tabs' }] },
            { title: 'Bar style', fields: [
                { key: 'barStyle', label: 'Style', type: 'select', options: ['underline', 'pills', 'boxed', 'segmented'],
                  hint: 'Look of the tab bar: underline (classic), pills (rounded buttons), boxed (folder tabs), or segmented (iOS-style switch).' },
                { key: 'align', label: 'Alignment', type: 'select', options: ['start', 'center', 'end', 'stretch'],
                  hint: 'Where the tabs sit: left, centred, right, or stretched to fill the width equally.' },
                { key: 'size', label: 'Size', type: 'select', options: ['sm', 'md', 'lg'], hint: 'Padding & font size of the tab buttons.' },
                { key: 'vertical', label: 'Vertical (bar on left)', type: 'bool', hint: 'Put the tab bar down the left side instead of across the top.' },
                { key: 'activeTab', label: 'Active tab (index)', type: 'number', hint: 'Which tab is open by default. 0 = the first tab, 1 = the second, …' },
                { key: 'barBg', label: 'Bar background (segmented)', type: 'color', hint: 'Background colour of the bar track — used by the “segmented” style.' },
                { key: 'activeColor', label: 'Active tab color', type: 'color', hint: 'Highlight colour of the selected tab. Blank = the theme’s primary colour.' }
            ] },
            styleGroup() ]; }
    });
    def('detail-grid', {
        label: 'Detail Grid', icon: 'bi-diagram-3', category: 'Data',
        defaults: function () { return { pageSize: 10, orderDir: 'ASC', allowAdd: true, allowEdit: true, allowView: true, allowDelete: false, showViewAll: true }; },
        render: function (n) {
            var p = n.props || {};
            var bound = p.table && p.fkColumn;
            var kb = window.PpDesignerBootstrap || {};
            var cols = dgCols(p);
            var t = p.title || (p.table ? title(p.table) : 'Detail Grid');
            var sub = bound
                ? esc(p.table) + '.' + esc(p.fkColumn) + ' = ' + esc((PPD.store && PPD.store.ui && PPD.store.ui('table')) || 'master') + '.' + esc(p.masterKey || 'id')
                : 'Configure: pick a detail table + FK column';
            // Designer styling (Section / Header / Grid) — reflects the generated app.
            var secCss = 'border:1px solid ' + (p.border || 'var(--ppd-border,#e2e8f0)') + ';border-radius:' + (p.radius || '12px') + ';background:' + (p.bg || 'var(--ppd-surface,#fff)') + ';padding:' + (p.pad || '14px') + ';';
            var headCss = 'display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px;'
                + (p.headerBg ? 'background:' + p.headerBg + ';border-radius:8px;padding:6px 10px;margin:-4px -4px 8px;' : '')
                + (p.headerColor ? 'color:' + p.headerColor + ';' : '');
            var cellPad = p.compact ? '2px 8px' : '5px 8px';
            // per-column selectable + draggable headers (hidden = dimmed)
            var head2 = cols.map(function (c) {
                var thc = 'text-align:' + (c.align || 'left') + ';padding:' + cellPad + ';border-bottom:1px solid var(--ppd-border,#e2e8f0);color:#64748b;font-weight:600;' + (c.width ? 'width:' + c.width + ';' : '');
                return '<th ' + partCls(n, 'dgcol:' + c.field, 'ppd-mdprevcol' + (c.visible === false ? ' ppd-part-hidden' : '')) +
                    ' style="' + esc(thc) + '">' + esc(c.label || title(c.field)) + ' <i class="bi bi-grip-vertical ppd-mdcol-grip"></i></th>';
            }).join('');
            var body2 = '';
            for (var r2 = 0; r2 < 3; r2++) {
                var stripe = (p.striped && r2 % 2 === 1) ? 'background:var(--ppd-surface-soft,#f8fafc);' : '';
                body2 += '<tr style="' + stripe + '">' + cols.map(function (c) {
                    return '<td style="text-align:' + (c.align || 'left') + ';padding:' + cellPad + ';border-bottom:1px solid var(--ppd-border,#eef2f7);' + (c.visible === false ? 'opacity:.35;' : '') + '">' +
                        '<span style="display:inline-block;width:' + (36 + (r2 * 17 % 40)) + 'px;height:8px;border-radius:4px;background:#e2e8f0;"></span></td>';
                }).join('') + '</tr>';
            }
            var titleCss = 'font-size:13px;color:' + (p.titleColor || 'inherit') + ';' + (p.titleBold ? 'font-weight:800;' : '');
            var badgeCss = mdBadgeCss(p.badgeSize || 'md', p.badgeShape || 'pill', p.badgeBg || '#e2e8f0', p.badgeColor || '#475569');
            var btnCss = function (bg, col) { return (bg ? 'background:' + bg + ';border-color:' + bg + ';' : '') + (col ? 'color:' + col + ';' : ''); };
            var headHtml = p.hideHeader ? '' :
                ('<strong ' + partCls(n, 'dgtitle', 'ppd-mdprevpart') + ' style="' + esc(titleCss) + '">' + icon(p.icon || 'bi-diagram-3') + ' ' + esc(t) + '</strong>'
                + '<span ' + partCls(n, 'dgbadge', 'ppd-mdprevpart ppd-md-badge' + (p.badgeHide ? ' ppd-part-hidden' : '')) + ' style="' + esc(badgeCss) + '">n</span>');
            return '<div class="ppd-detailgrid" style="' + esc(secCss) + '">'
                + '<div class="ppd-mdprevhead" style="' + esc(headCss) + '">'
                + headHtml
                + '<span style="flex:1"></span>'
                + (p.allowAdd !== false ? '<span ' + partCls(n, 'dgadd', 'ppd-mdprevpart ppd-btn' + (p.addHide ? ' ppd-part-hidden' : '')) + ' style="font-size:11px;' + esc(btnCss(p.addBg, p.addColor)) + '">' + icon(p.addIcon || 'bi-plus-lg') + ' ' + esc(p.addLabel || 'Add') + '</span>' : '')
                + (p.showViewAll !== false ? '<span ' + partCls(n, 'dggo', 'ppd-mdprevpart ppd-btn' + (p.goHide ? ' ppd-part-hidden' : '')) + ' style="font-size:11px;' + esc(btnCss(p.goBg, p.goColor)) + '">' + icon(p.goIcon || 'bi-box-arrow-up-right') + ' ' + esc(p.goLabel || 'View all') + '</span>' : '')
                + '</div>'
                + '<div style="overflow:hidden;"><table class="ppd-mdprevgrid" style="width:100%;border-collapse:collapse;font-size:11px;"><thead><tr>' + head2 + '</tr></thead><tbody>' + body2 + '</tbody></table></div>'
                + '<small style="color:#64748b;">' + icon(bound ? 'bi-link-45deg' : 'bi-exclamation-circle') + ' ' + sub + ' · drag columns to reorder</small></div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'title', label: 'Title', type: 'text' },
            { key: 'icon', label: 'Header icon', type: 'icon' },
            { key: 'table', label: 'Detail table', type: 'dbtable' },
            { key: 'fkColumn', label: 'FK column (in detail table)', type: 'dbcolumn', tableKey: 'table' },
            { key: 'masterKey', label: 'Master key (this table)', type: 'dbcolumn', tableKey: '__page__' },
            { key: 'columns', label: 'Columns (blank = auto)', type: 'csv' },
            { key: 'pageSize', label: 'Rows shown', type: 'number' },
            { key: 'orderDir', label: 'Order (by id)', type: 'select', options: ['ASC', 'DESC'] },
            { key: 'allowAdd', label: 'Add button (pre-fills the FK)', type: 'bool' },
            { key: 'allowView', label: 'View action', type: 'bool' },
            { key: 'allowEdit', label: 'Edit action', type: 'bool' },
            { key: 'allowDelete', label: 'Delete action', type: 'bool' },
            { key: 'showViewAll', label: '“View all” link (filtered list)', type: 'bool' },
            { key: 'hideIfEmpty', label: 'Hide when there are no records', type: 'bool' }
        ]), { title: 'Section', fields: [
            { key: 'bg', label: 'Background', type: 'color' },
            { key: 'border', label: 'Border color', type: 'color' },
            { key: 'radius', label: 'Corner radius', type: 'text', placeholder: 'e.g. 12px' },
            { key: 'pad', label: 'Padding', type: 'text', placeholder: 'e.g. 14px' }
        ] }, { title: 'Header', fields: [
            { key: 'headerBg', label: 'Header background', type: 'color' },
            { key: 'headerColor', label: 'Header text', type: 'color' },
            { key: 'hideHeader', label: 'Hide the title bar', type: 'bool' }
        ] }, { title: 'Grid', fields: [
            { key: 'striped', label: 'Striped rows', type: 'bool' },
            { key: 'compact', label: 'Compact rows', type: 'bool' }
        ] }, styleGroup() ]; }
    });
    def('kpi-row', {
        label: 'KPI Row', icon: 'bi-grid-1x2', category: 'Data',
        defaults: function () { return { items: [{ label: 'Total', value: '1,248', icon: 'bi-database' }, { label: 'Active', value: '932', icon: 'bi-check-circle' }, { label: 'Pending', value: '64', icon: 'bi-hourglass' }] }; },
        render: function (n) {
            // Tiles may be data-bound (select a tile to bind it); bound tiles
            // are upgraded to live values by the renderer's data pass.
            return '<div class="ppd-stat-cards">' + (n.props.items || []).map(function (it, i) {
                var bound = !!it.table;
                var sub = bound ? '<small style="color:#94a3b8;font-size:10px;">' + esc((it.agg || 'count') + ' · ' + it.table) + '</small>' : '';
                return '<div ' + partCls(n, 'kpi:' + i, 'ppd-stat') + '><div class="v" style="color:' + esc(it.color || 'inherit') + ';">' +
                    esc((it.prefix || '') + (bound ? '…' : (it.value || '0')) + (it.suffix || '')) + '</div>' +
                    '<div class="l">' + icon(it.icon) + ' ' + esc(it.label) + '</div>' + sub + '</div>';
            }).join('') + '</div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'items', label: 'Tiles', type: 'list', itemLabel: 'Tile', addLabel: 'Add tile',
              default: { label: 'New tile', value: '0', icon: 'bi-dot' },
              item: [
                { key: 'label', label: 'Label', type: 'text' },
                { key: 'value', label: 'Value', type: 'text' },
                { key: 'icon', label: 'Icon', type: 'icon' },
                { key: 'color', label: 'Value color', type: 'color' }
              ] }
        ]), styleGroup() ]; }
    });

    /* ---------------- legacy compatibility (older saved pages) ---------------- */
    def('cell', {
        label: 'Cell', icon: 'bi-square', category: 'Layout', container: true,
        defaults: function () { return { span: 6 }; },
        render: function (n, ctx) { return ctx.renderChildren(n, 'ppd-cell-zone'); },
        schema: function () { return [ generalGroup([{ key: 'span', label: 'Span (1-12)', type: 'number' }]), styleGroup() ]; }
    });
    def('page-size', {
        label: 'Page Size', icon: 'bi-list-ol', category: 'Navigation',
        defaults: function () { return { sizes: [10, 20, 50, 100], default: 10, label: 'Show' }; },
        render: function (n) { return '<span class="ppd-tb-pagesize"><span class="lbl">' + esc(n.props.label || 'Show') + '</span><span class="box">' + esc(n.props.default || 10) + ' <i class="bi bi-caret-down-fill"></i></span></span>'; },
        schema: function () { return [ generalGroup([{ key: 'sizes', label: 'Sizes', type: 'csv' }, { key: 'default', label: 'Default', type: 'text' }]), styleGroup() ]; }
    });
    def('button-group', {
        label: 'Button Group', icon: 'bi-segmented-nav', category: 'Navigation',
        defaults: function () { return { table: '', buttons: [{ label: 'Add', icon: 'bi-plus-lg', action: 'add', variant: 'primary' }, { label: 'Export', icon: 'bi-download', action: 'export', variant: 'default' }], orientation: 'horizontal', size: 'md', attached: false }; },
        render: function (n) {
            var p = n.props || {};
            var btns = (p.buttons || []).filter(function (b) { return b && String(b.label || '').trim(); }).map(function (b) {
                var v = (b.variant === 'primary' || b.variant === 'danger') ? ' ' + b.variant : '';
                return '<a class="ppd-bg-btn' + v + '">' + (b.icon ? icon(b.icon) : '') + '<span>' + esc(b.label) + '</span></a>';
            }).join('');
            var needsTable = (p.buttons || []).some(function (b) { return b && b.action && b.action !== 'link'; });
            var badge = (needsTable && !p.table) ? ' <i class="bi bi-exclamation-triangle ppd-dynbadge" title="Pick a table for Add/List/Print/Export" style="color:var(--pp-warning,#f59e0b)"></i>' : '';
            return '<div class="ppd-c-buttons ppd-btngroup bg-' + (p.orientation === 'vertical' ? 'vert' : 'horz') + ' bg-' + (p.size || 'md') + (p.attached ? ' is-attached' : '') + '">' + btns + badge + '</div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'table', label: 'Table (for built-in actions)', type: 'dbtable', hint: 'Used by the Add / List / Print / Export actions below.' },
            { key: 'buttons', label: 'Buttons', type: 'list', itemLabel: 'Button', addLabel: 'Add button', item: [
                { key: 'label', label: 'Label', type: 'text', hint: 'Button text.' },
                { key: 'icon', label: 'Icon', type: 'icon', hint: 'Icon before the label.' },
                { key: 'action', label: 'Action', type: 'select', options: ['link', 'add', 'list', 'print', 'export'], hint: 'link = your own URL · add / list / print / export use the Table above.' },
                { key: 'href', label: 'URL (for “link”)', type: 'text', placeholder: 'https://…', hint: 'Where a “link” action goes.' },
                { key: 'variant', label: 'Style', type: 'select', options: ['default', 'primary', 'danger'], hint: 'Button colour.' },
                { key: 'newTab', label: 'New tab', type: 'bool', hint: 'Open in a new browser tab.' }
            ] },
            { key: 'orientation', label: 'Orientation', type: 'select', options: ['horizontal', 'vertical'], hint: 'Lay the buttons in a row or a column.' },
            { key: 'size', label: 'Size', type: 'select', options: ['sm', 'md', 'lg'], hint: 'Button size.' },
            { key: 'attached', label: 'Segmented (joined)', type: 'bool', hint: 'Join the buttons into one segmented control.' }
        ]), styleGroup() ]; }
    });

    /* ---------------- import / export building blocks ---------------- */
    def('workflow-steps', {
        label: 'Workflow Steps', icon: 'bi-list-ol', category: 'Navigation',
        defaults: function () { return { steps: [{ label: 'Source' }, { label: 'Mapping' }, { label: 'Preview' }, { label: 'Import' }], activeSource: 'manual', active: 0, orientation: 'horizontal', size: 'md' }; },
        render: function (n) {
            var p = n.props || {};
            // Normalise legacy CSV-of-strings into {label} rows so old nodes render
            // identically to the generated app (mirrors ComponentRenderer).
            var steps = (p.steps || []).map(function (s) { return (typeof s === 'string') ? { label: s } : (s || {}); })
                .filter(function (s) { return String(s.label || '').trim(); });
            var act = parseInt(p.active, 10) || 0;
            var vert = p.orientation === 'vertical';
            var body = steps.map(function (s, i) {
                var cls = i < act ? 'done' : i === act ? 'active' : '';
                var mark = i < act ? '<i class="bi bi-check-lg"></i>' : (s.icon ? icon(s.icon) : (i + 1));
                var desc = s.description ? '<small class="ppd-wf-desc">' + esc(s.description) + '</small>' : '';
                return '<div class="ppd-wf-step ' + cls + '"><span class="ppd-wf-no">' + mark + '</span><span class="ppd-wf-txt"><span class="ppd-wf-lbl">' + esc(s.label) + '</span>' + desc + '</span></div>' +
                    (i < steps.length - 1 ? '<div class="ppd-wf-bar"></div>' : '');
            }).join('');
            var badge = (p.activeSource === 'dynamic') ? ' <i class="bi bi-database ppd-dynbadge" title="Active step from SQL"></i>' : '';
            return '<div class="ppd-wf ppd-wf-' + (vert ? 'vert' : 'horz') + ' ppd-wf-' + (p.size || 'md') + '">' + body + badge + '</div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'steps', label: 'Steps', type: 'list', itemLabel: 'Step', addLabel: 'Add step', item: [
                { key: 'label', label: 'Label', type: 'text', hint: 'Step name.' },
                { key: 'description', label: 'Description', type: 'text', hint: 'Small text under the label (optional).' },
                { key: 'icon', label: 'Icon', type: 'icon', hint: 'Icon in the circle for upcoming/active steps (done steps show a check).' }
            ] },
            { key: 'orientation', label: 'Orientation', type: 'select', options: ['horizontal', 'vertical'], hint: 'A horizontal stepper or a vertical checklist.' },
            { key: 'size', label: 'Size', type: 'select', options: ['sm', 'md', 'lg'], hint: 'Overall size.' },
            { key: 'activeSource', label: 'Active step from', type: 'radiogroup', options: ['manual', 'dynamic'], rerender: true, hint: 'Manual = fixed step. Dynamic = a SQL value decides how far along it is.' },
            { key: 'active', label: 'Active step (0-based)', type: 'number', when: { key: 'activeSource', in: ['manual', ''] }, hint: '0 = first step. Earlier steps show as done.' },
            { key: 'query', label: 'SQL (returns active index)', type: 'text', full: true, when: { key: 'activeSource', in: ['dynamic'] },
              placeholder: 'SELECT COUNT(*) FROM tasks WHERE done = 1 AND user_id = :userid',
              hint: 'A SELECT returning ONE number = the 0-based active step (e.g. count of completed steps). Runs on page load; supports SQL variables.',
              note: 'Return the 0-based active step index (or the count of completed steps). Supports <code>:userid</code>, <code>:today</code>, <code>:field.name</code>.' }
        ]), styleGroup() ]; }
    });
    def('radio-group', {
        label: 'Radio Group', icon: 'bi-ui-radios', category: 'Form',
        defaults: function () { return { label: 'Choose', options: ['Option A', 'Option B'], value: '' }; },
        render: function (n) {
            var p = n.props; var opts = p.options || [];
            return '<div class="ppd-c-field"><label>' + esc(p.label || '') + '</label><div class="ppd-c-radios">' + opts.map(function (o, i) {
                return '<label><input type="radio" disabled ' + ((p.value === o || (!p.value && i === 0)) ? 'checked' : '') + '> ' + esc(o) + '</label>';
            }).join('') + '</div></div>';
        },
        schema: function () { return [ generalGroup([{ key: 'label', label: 'Label', type: 'text' }, { key: 'options', label: 'Options', type: 'csv' }, { key: 'value', label: 'Default', type: 'text' }]), styleGroup() ]; }
    });
    def('format-selector', {
        label: 'Format Selector', icon: 'bi-filetype-csv', category: 'Data',
        // Defaults are DataOps registry ids, so the tiles show exactly the
        // formats the generated Export screen can offer.
        defaults: function () {
            var known = Object.keys(dataFormats());
            return { formats: known.length ? known.filter(function (k) { return k !== 'excel_html'; }) : ['csv', 'xlsx', 'word', 'json', 'xml'], value: 'csv' };
        },
        render: function (n) {
            var p = n.props; var fmts = p.formats || [];
            return '<div class="ppd-format-grid">' + fmts.map(function (f) {
                var k = String(f).toLowerCase();
                return '<div class="ppd-format-tile' + (String(p.value).toLowerCase() === k ? ' active' : '') + '">'
                    + fmtIcon(k) + '<span>' + esc(fmtLabel(k)) + '</span></div>';
            }).join('') + '</div>';
        },
        schema: function () { return [ generalGroup([{ key: 'formats', label: 'Formats', type: 'csv' }, { key: 'value', label: 'Default', type: 'text' }]), styleGroup() ]; }
    });
    def('field-mapper', {
        label: 'Field Mapper', icon: 'bi-arrow-left-right', category: 'Data',
        defaults: function () { return {}; },
        // rows are the REAL table fields (targets); the source column is picked from the imported file at runtime
        render: function (n, ctx) {
            var targets = (ctx && ctx.fields) || [];
            if (!targets.length) return '<div class="ppd-mapper"><div class="ppd-mapper-row"><span class="ppd-mapper-note">No table fields — select a table.</span></div></div>';
            return '<div class="ppd-mapper"><div class="ppd-mapper-row head"><span>Table field</span><span></span><span>Map from</span></div>' +
                targets.map(function (f) {
                    return '<div class="ppd-mapper-row"><span class="ppd-mapper-src">' + esc(f.label || title(f.name)) + '</span><i class="bi bi-arrow-left-short"></i><select disabled><option>— source column —</option></select></div>';
                }).join('') + '</div>';
        },
        schema: function () { return [ generalGroup([]), styleGroup() ]; }
    });
    def('summary', {
        label: 'Summary', icon: 'bi-clipboard-data', category: 'Data',
        defaults: function () { return { items: [{ label: 'Rows', value: '250', icon: 'bi-list-ol' }, { label: 'Fields', value: '8', icon: 'bi-layout-three-columns' }, { label: 'Warnings', value: '1', icon: 'bi-exclamation-triangle', tone: 'warn' }, { label: 'Errors', value: '0', icon: 'bi-check-circle', tone: 'ok' }], columns: 'auto', size: 'md', align: 'left', shadow: 'none' }; },
        render: function (n) {
            var p = n.props || {};
            var spanned = (p.items || []).some(function (it) { return it && (kpiSpan(it.spanD) || kpiSpan(it.spanT) || kpiSpan(it.spanM)); });
            var cls = 'ppd-summary' + (spanned ? ' is-spanned' : '') + (p.columns && p.columns !== 'auto' ? ' cols-' + p.columns : '') + (p.align === 'center' ? ' is-center' : '') + (['sm', 'lg'].indexOf(p.size) > -1 ? ' size-' + p.size : '');
            var itemShadow = ['sm', 'md', 'lg'].indexOf(p.shadow) > -1 ? ' shadow-' + p.shadow : '';
            var head = (p.heading || '').trim() ? '<div class="ppd-summary-heading">' + esc(p.heading) + '</div>' : '';
            var body = (p.items || []).map(function (it, idx) {
                if (!(it && (String(it.label || '').trim() || it.table || it.value))) return '';
                var dyn = !!it.table;
                var val = dyn ? (esc(it.prefix || '') + '1,248' + esc(it.suffix || '')) : esc((it.prefix || '') + (it.value || '0') + (it.suffix || ''));
                var badge = dyn ? ' <i class="bi bi-database ppd-dynbadge" title="Live from ' + esc(title(it.table)) + '"></i>' : (it.linkTable ? ' <i class="bi bi-link-45deg ppd-dynbadge" title="Links to ' + esc(title(it.linkTable)) + '"></i>' : '');
                var vStyle = it.valueColor ? ' style="color:' + esc(it.valueColor) + '"' : '';
                var st = (p.itemBg ? 'background:' + esc(p.itemBg) + ';' : '') + kpiSpanVars(it);
                return '<div class="ppd-summary-item ppd-part tone-' + (it.tone || '') + itemShadow + '" data-ppd-id="' + esc(n.id) + '" data-ppd-part="kpi:' + idx + '"' + (st ? ' style="' + st + '"' : '') + '>' + icon(it.icon || 'bi-dot') + '<div><strong' + vStyle + '>' + val + '</strong><small>' + esc(it.label) + badge + '</small></div></div>';
            }).join('');
            return head + '<div class="' + cls + '"' + (p.gap ? ' style="gap:' + esc(p.gap) + '"' : '') + '>' + body + '</div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Block heading', type: 'text', hint: 'Optional title shown above the summary items.' },
            { key: 'items', label: 'Items', type: 'list', itemLabel: 'Item', addLabel: 'Add item', item: [
                { key: 'label', label: 'Label', type: 'text', hint: 'Caption under the value (e.g. “Total sales”).' },
                { key: 'icon', label: 'Icon', type: 'icon', hint: 'Icon shown beside the value.' },
                { key: 'tone', label: 'Tone', type: 'select', options: ['', 'ok', 'warn', 'danger', 'info'], hint: 'Colours the icon: ok=green · warn=amber · danger=red · info=blue.' },
                { key: 'valueColor', label: 'Value colour', type: 'color', hint: 'Colour of this item’s number.' },
                { key: 'table', label: 'Live from table', type: 'dbtable', hint: 'Optional — makes this a live DB aggregate. Blank = use the manual Value below.' },
                { key: 'agg', label: 'Aggregate', type: 'select', options: ['count', 'sum', 'avg', 'min', 'max'], hint: 'count = rows · sum/avg/min/max = over the column.' },
                { key: 'column', label: 'Column', type: 'column', fromItem: 'table', hint: 'Number column for sum / avg / min / max.' },
                { key: 'where', label: 'Filter (WHERE)', type: 'text', hint: 'Optional SQL condition — supports SQL variables (:userid, :today…).' },
                { key: 'decimals', label: 'Decimals', type: 'number', hint: 'Digits after the decimal point for a live value (0–4).' },
                { key: 'abbreviate', label: 'Abbreviate (1.2k)', type: 'bool', boolLabel: 'shorten big numbers', hint: '48,200 → 48.2k for a live value.' },
                { key: 'value', label: 'Value (manual)', type: 'text', hint: 'Shown when no table is set.' },
                { key: 'prefix', label: 'Prefix', type: 'text', hint: 'Text before the number, e.g. “$”.' },
                { key: 'suffix', label: 'Suffix', type: 'text', hint: 'Text after the number, e.g. “%”.' },
                { key: 'linkTable', label: 'Click → open table', type: 'dbtable', hint: 'Optional — clicking this item opens that table’s list page.' }
            ] }
        ]), { title: 'Layout', fields: [
            { key: 'columns', label: 'Columns', type: 'select', options: ['auto', '2', '3', '4'], hint: 'auto fits as many as fit per row; or fix 2 / 3 / 4.' },
            { key: 'align', label: 'Align', type: 'select', options: ['left', 'center'], hint: 'Content alignment inside each item.' },
            { key: 'size', label: 'Value size', type: 'select', options: ['sm', 'md', 'lg'], hint: 'Size of the numbers.' },
            { key: 'gap', label: 'Gap', type: 'text', placeholder: '12px', hint: 'Spacing between items, e.g. 12px or 20px.' },
            { key: 'shadow', label: 'Item shadow', type: 'select', options: ['none', 'sm', 'md', 'lg'], hint: 'Drop-shadow on each item card.' },
            { key: 'itemBg', label: 'Item background', type: 'color', hint: 'Background colour of each item card.' }
        ] }, styleGroup() ]; }
    });

    /* AI Assistant — Claude-powered per-record helper (Summarise / Next actions /
       Draft). Live behaviour is wired by app.js in the generated app; the canvas
       shows a faithful static preview. Mirrors ComponentRenderer 'ai-assist'. */
    def('ai-assist', {
        label: 'AI Assistant', icon: 'bi-stars', category: 'Data',
        defaults: function () { return { heading: 'AI Assistant', pickerLabel: 'Choose a record…', summaryLabel: 'Summarise', nextLabel: 'Next actions', draftLabel: 'Draft notice' }; },
        render: function (n) {
            var p = n.props || {};
            var opt = p.table ? '<option>' + esc(title(p.table)) + ' record…</option>' : '<option>Pick a table →</option>';
            return '<div class="pp-ai-assist">'
                + '<div class="pp-ai-head">' + icon('bi-stars') + '<strong>' + esc(p.heading || 'AI Assistant') + '</strong>'
                + '<button type="button" class="pp-ai-help">?</button></div>'
                + '<div class="pp-ai-controls"><select class="pp-ai-pick"><option>' + esc(p.pickerLabel || 'Choose a record…') + '</option>' + opt + '</select>'
                + '<div class="pp-ai-tasks">'
                + '<button type="button" class="pp-ai-task">' + esc(p.summaryLabel || 'Summarise') + '</button>'
                + '<button type="button" class="pp-ai-task">' + esc(p.nextLabel || 'Next actions') + '</button>'
                + '<button type="button" class="pp-ai-task">' + esc(p.draftLabel || 'Draft notice') + '</button>'
                + '</div></div>'
                + '<div class="pp-ai-out"><div class="pp-ai-hint">Pick a record, then choose an action — the connected AI answers here.</div></div></div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text' },
            { key: 'table', label: 'Records table', type: 'dbtable', hint: 'The table the assistant works on — each row becomes a pickable record.' },
            { key: 'labelColumn', label: 'Record label column', type: 'dbcolumn', tableKey: 'table', hint: 'Shown in the picker for each record (e.g. a case number).' },
            { key: 'pickerLabel', label: 'Picker placeholder', type: 'text' },
            { key: 'summaryLabel', label: '“Summarise” button', type: 'text' },
            { key: 'nextLabel', label: '“Next actions” button', type: 'text' },
            { key: 'draftLabel', label: '“Draft” button', type: 'text' }
        ]), styleGroup() ]; }
    });

    /* Cause List — upcoming records within N days, grouped by a column, as a
       printable table. Mirrors ComponentRenderer 'cause-list'. */
    def('cause-list', {
        label: 'Cause List', icon: 'bi-list-check', category: 'Data',
        defaults: function () { return {
            heading: 'Cause List', days: 7, printLabel: 'Print', emptyLabel: 'No records in this window.',
            // Fully configurable columns — blank hides that column, so the component
            // works on ANY table. The generated renderer reads these same keys.
            numberColumn: '', partyAColumn: '', partyBColumn: '', vsLabel: 'vs',
            dateDisplayColumn: '', typeColumn: '', repColumn: '',
            dateSystem: 'bs', bsPrefix: 'वि.सं.', adPrefix: 'इ.सं.',
            colNo: '#', colCase: 'No.', colParties: 'Parties', colDate: 'Date', colType: 'Type', colRep: 'Rep'
        }; },
        render: function (n) {
            var p = n.props || {};
            var bound = p.table && p.dateColumn && p.groupColumn;
            // Mirror the build: serial + date always; other columns show only when bound.
            var cols = [p.colNo || '#'];
            if (p.numberColumn) cols.push(p.colCase || 'No.');
            if (p.partyAColumn) cols.push(p.colParties || 'Parties');
            cols.push(p.colDate || 'Date');
            if (p.typeColumn) cols.push(p.colType || 'Type');
            if (p.repColumn) cols.push(p.colRep || 'Rep');
            var thead = cols.map(function (c) { return '<th>' + esc(c) + '</th>'; }).join('');
            var vs = esc(p.vsLabel || 'vs');
            var row = function (i) {
                var tds = '<td>' + i + '</td>';
                if (p.numberColumn) tds += '<td>0-CV-00' + i + '</td>';
                if (p.partyAColumn) tds += '<td>Sample ' + vs + ' Sample</td>';
                tds += '<td>—</td>';
                if (p.typeColumn) tds += '<td>—</td>';
                if (p.repColumn) tds += '<td>—</td>';
                return '<tr>' + tds + '</tr>';
            };
            var body = bound
                ? '<div class="pp-cl-court">' + esc(title(p.groupColumn)) + ' <span>(2)</span></div>'
                    + '<table class="pp-cl-t"><thead><tr>' + thead + '</tr></thead><tbody>' + row(1) + row(2) + '</tbody></table>'
                : '<div class="pp-cl-empty">Bind a table, a date column and a group-by column →</div>';
            return '<div class="pp-cl"><div class="pp-cl-bar"><button type="button" class="pp-cl-print">' + icon('bi-printer') + ' ' + esc(p.printLabel || 'Print') + '</button></div>'
                + '<div class="pp-cl-doc"><div class="pp-cl-h"><strong>' + esc(p.heading || 'Cause List') + '</strong><span>' + new Date().toISOString().slice(0, 10) + '</span></div>'
                + body + '</div></div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text' },
            { key: 'table', label: 'Source (table or view)', type: 'dbtable', hint: 'Point at a reporting view to show joined names.' },
            { key: 'dateColumn', label: 'Date column (window)', type: 'dbcolumn', tableKey: 'table', hint: 'Records within the next N days by this date are listed.' },
            { key: 'groupColumn', label: 'Group by column', type: 'dbcolumn', tableKey: 'table', hint: 'Rows are grouped under each value (e.g. a court name).' },
            { key: 'days', label: 'Window (days ahead)', type: 'number', hint: 'How many days ahead to include (e.g. 7).' },
            { key: 'dateSystem', label: 'Date display', type: 'select', options: [['bs', 'Nepali (BS, Devanagari)'], ['ad', 'English (AD)'], ['both', 'Both — BS (AD)']], hint: 'A “…_bs” display column is shown in Devanagari; an AD column is converted to Bikram Sambat.' },
            { key: 'bsPrefix', label: 'Nepali (BS) prefix', type: 'text' },
            { key: 'adPrefix', label: 'English (AD) prefix', type: 'text' },
            { key: 'printLabel', label: 'Print button label', type: 'text' },
            { key: 'emptyLabel', label: 'Empty message', type: 'text' }
        ]),
        { title: 'Columns & headers', fields: [
            { key: 'colNo', label: 'Serial header', type: 'text', hint: 'The always-present row counter column.' },
            { key: 'numberColumn', label: 'Reference column', type: 'dbcolumn', tableKey: 'table', hint: 'e.g. a case/file number. Blank = hide this column.' },
            { key: 'colCase', label: 'Reference header', type: 'text' },
            { key: 'partyAColumn', label: 'Party A column', type: 'dbcolumn', tableKey: 'table', hint: 'e.g. plaintiff. Blank = hide the parties column.' },
            { key: 'partyBColumn', label: 'Party B column', type: 'dbcolumn', tableKey: 'table', hint: 'e.g. defendant.' },
            { key: 'vsLabel', label: '“versus” word', type: 'text', hint: 'Joins Party A and Party B (e.g. vs, विरुद्ध).' },
            { key: 'colParties', label: 'Parties header', type: 'text' },
            { key: 'dateDisplayColumn', label: 'Display-date column', type: 'dbcolumn', tableKey: 'table', hint: 'Shown in the Date cell (e.g. a BS date). Blank = the window date column.' },
            { key: 'colDate', label: 'Date header', type: 'text' },
            { key: 'typeColumn', label: 'Type column', type: 'dbcolumn', tableKey: 'table', hint: 'Blank = hide this column.' },
            { key: 'colType', label: 'Type header', type: 'text' },
            { key: 'repColumn', label: 'Representative column', type: 'dbcolumn', tableKey: 'table', hint: 'Blank = hide this column.' },
            { key: 'colRep', label: 'Representative header', type: 'text' }
        ]}, styleGroup() ]; }
    });

    /* Deadline Tracker — a risk-prioritised watchlist of upcoming (and overdue)
       dates: statutes of limitation, filing deadlines, hearings. Buckets each row
       by days-remaining (Overdue / Critical / Due soon / Upcoming) with a colour
       dot + day countdown. Mirrors ComponentRenderer 'deadline-tracker'. */
    def('deadline-tracker', {
        label: 'Deadline Tracker', icon: 'bi-alarm', category: 'Data',
        defaults: function () { return {
            heading: 'Deadlines & Limitations',
            criticalDays: 3, soonDays: 7, horizonDays: 30, showOverdue: true,
            overdueLabel: 'Overdue', criticalLabel: 'Critical', soonLabel: 'Due soon', upcomingLabel: 'Upcoming',
            dueInLabel: 'in', overdueByLabel: 'overdue', daysLabel: 'days', dayLabel: 'day', todayLabel: 'today',
            emptyLabel: 'No upcoming deadlines.', openLabel: 'Open'
        }; },
        render: function (n) {
            var p = n.props || {};
            var H = esc(p.heading || 'Deadlines & Limitations');
            if (!(p.table && p.dateColumn)) {
                return '<div class="pp-dl"><div class="pp-dl-head">' + icon('bi-alarm') + '<strong>' + H + '</strong></div>'
                    + '<div class="pp-dl-empty">Bind a table and a deadline date column →</div></div>';
            }
            // Respect explicit-empty labels (a set-but-blank prefix means "no prefix"),
            // matching the generated renderer — undefined falls back to the default.
            var L = function (k, d) { return esc(p[k] != null ? p[k] : d); };
            var pre = function (k, d) { var v = L(k, d); return v ? v + ' ' : ''; };
            var sample = [
                { r: 'overdue', t: '0-CV-014', s: L('overdueLabel', 'Overdue'), d: (pre('overdueByLabel', 'overdue') + '2 ' + L('daysLabel', 'days')).trim() },
                { r: 'critical', t: '0-CV-021', s: 'Hearing', d: (pre('dueInLabel', 'in') + '1 ' + L('dayLabel', 'day')).trim() },
                { r: 'soon', t: '0-CV-009', s: 'Filing', d: (pre('dueInLabel', 'in') + '5 ' + L('daysLabel', 'days')).trim() },
                { r: 'upcoming', t: '0-CV-032', s: 'Hearing', d: (pre('dueInLabel', 'in') + '21 ' + L('daysLabel', 'days')).trim() }
            ];
            var chips = '<span class="pp-dl-chip r-overdue">1 ' + esc(p.overdueLabel || 'Overdue') + '</span>'
                + '<span class="pp-dl-chip r-critical">1 ' + esc(p.criticalLabel || 'Critical') + '</span>'
                + '<span class="pp-dl-chip r-soon">1 ' + esc(p.soonLabel || 'Due soon') + '</span>';
            var items = sample.map(function (it) {
                return '<div class="pp-dl-item r-' + it.r + '"><span class="pp-dl-dot"></span>'
                    + '<div class="pp-dl-main"><div class="pp-dl-t">' + it.t + '</div><div class="pp-dl-s">' + it.s + '</div></div>'
                    + '<span class="pp-dl-badge">' + it.d + '</span></div>';
            }).join('');
            return '<div class="pp-dl"><div class="pp-dl-head">' + icon('bi-alarm') + '<strong>' + H + '</strong><span class="pp-dl-chips">' + chips + '</span></div>'
                + '<div class="pp-dl-list">' + items + '</div></div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text' },
            { key: 'table', label: 'Source (table or view)', type: 'dbtable', hint: 'The records whose deadlines to watch.' },
            { key: 'dateColumn', label: 'Deadline date column', type: 'dbcolumn', tableKey: 'table', hint: 'The date to count down to (limitation / filing / hearing).' },
            { key: 'titleColumn', label: 'Title column', type: 'dbcolumn', tableKey: 'table', hint: 'Main label for each row (e.g. a case number).' },
            { key: 'subtitleColumn', label: 'Subtitle column', type: 'dbcolumn', tableKey: 'table', hint: 'Optional second line (e.g. deadline type or party). Blank = hide.' },
            { key: 'where', label: 'Filter (WHERE)', type: 'text', hint: 'Optional — only watch matching rows. Supports :userid, :today…' }
        ]),
        { title: 'Thresholds & risk', fields: [
            { key: 'showOverdue', label: 'Include overdue', type: 'bool', hint: 'Show rows whose deadline has already passed (red).' },
            { key: 'criticalDays', label: 'Critical within (days)', type: 'number', hint: 'Days-remaining at or under this are Critical (orange).' },
            { key: 'soonDays', label: 'Due-soon within (days)', type: 'number', hint: 'Days-remaining at or under this are Due soon (amber).' },
            { key: 'horizonDays', label: 'Horizon (days ahead)', type: 'number', hint: 'How far ahead to include upcoming deadlines.' }
        ]},
        { title: 'Labels', fields: [
            { key: 'overdueLabel', label: 'Overdue label', type: 'text' },
            { key: 'criticalLabel', label: 'Critical label', type: 'text' },
            { key: 'soonLabel', label: 'Due-soon label', type: 'text' },
            { key: 'upcomingLabel', label: 'Upcoming label', type: 'text' },
            { key: 'dueInLabel', label: '“in” (before count)', type: 'text', hint: 'e.g. “in” → “in 5 days”. Blank for languages that put the number first.' },
            { key: 'overdueByLabel', label: '“overdue” (before count)', type: 'text' },
            { key: 'daysLabel', label: '“days” word', type: 'text' },
            { key: 'dayLabel', label: '“day” (singular)', type: 'text' },
            { key: 'todayLabel', label: '“today” word', type: 'text' },
            { key: 'emptyLabel', label: 'Empty message', type: 'text' }
        ]},
        { title: 'Open link', fields: [
            { key: 'keyColumn', label: 'Record key column', type: 'dbcolumn', tableKey: 'table', hint: 'Primary key for the row’s link (default id).' },
            { key: 'linkTable', label: '“Open” → table', type: 'dbtable', hint: 'Table whose view page each row links to. Blank = no link.' },
            { key: 'openLabel', label: '“Open” label', type: 'text' }
        ]}, styleGroup() ]; }
    });

    /* Document Generator — legal document automation. Pick a record, merge its
       fields into a template ({{column}} tokens), preview + print/download. Works
       WITHOUT AI (pure template merge). Record data is fetched RLS-scoped at
       request time. Mirrors ComponentRenderer 'doc-generator'. */
    def('doc-generator', {
        label: 'Document Generator', icon: 'bi-file-earmark-text', category: 'Data',
        defaults: function () { return {
            heading: 'Document Generator',
            pickerLabel: 'Choose a record…', generateLabel: 'Generate', printLabel: 'Print', downloadLabel: 'Text', pdfLabel: 'PDF',
            emptyLabel: 'Pick a record and Generate to preview the document.',
            // Date preference: bs = Bikram Sambat (वि.सं.), ad = Gregorian (इ.सं.), both.
            // {{today}} renders in the chosen system; any {{col_bs}} / {{col_ad}} field
            // is auto-prefixed with the era. {{time}} = the chosen hearing time.
            dateSystem: 'bs', bsPrefix: 'वि.सं.', adPrefix: 'इ.सं.', defaultTime: '10:00', showTime: true, timeLabel: 'Time', timeFormat: '24',
            letterhead: '', letterheadLogo: '', letterheadAlign: 'center',
            emailColumn: '', phoneColumn: '', sendSubject: '', emailLabel: 'Email', smsLabel: 'SMS',
            template: 'To: {{plaintiff}}\nCase No.: {{case_number}}\nCourt: {{court_name}}\n\nThis is to notify you that the next hearing in the above case is scheduled for {{next_hearing_date_bs}} at {{time}}.\n\nDate: {{today}}'
        }; },
        render: function (n) {
            var p = n.props || {};
            var tpl = String(p.template || '');
            var hi = function (s) { return esc(s).replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, '<span class="pp-dg-tok">{{$1}}</span>'); };
            var timeInput = (p.showTime !== false) ? '<input type="time" class="pp-dg-time" value="' + esc(p.defaultTime || '10:00') + '">' : '';
            var lh = String(p.letterhead || '');
            var lhHtml = (lh || p.letterheadLogo)
                ? '<div class="pp-dg-letterhead" style="text-align:' + esc(p.letterheadAlign || 'center') + '">'
                    + (p.letterheadLogo ? '<img class="pp-dg-lh-logo" src="' + esc(p.letterheadLogo) + '" alt="">' : '')
                    + (lh ? '<div class="pp-dg-lh-text">' + hi(lh).replace(/\n/g, '<br>') + '</div>' : '') + '</div>'
                : '';
            return '<div class="pp-dg"><div class="pp-dg-head">' + icon('bi-file-earmark-text') + '<strong>' + esc(p.heading || 'Document Generator') + '</strong></div>'
                + '<div class="pp-dg-bar"><select class="pp-dg-pick"><option>' + esc(p.pickerLabel || 'Choose a record…') + '</option>'
                + (p.table ? '<option>' + esc(title(p.table)) + ' record…</option>' : '') + '</select>'
                + timeInput
                + '<button type="button" class="pp-dg-gen">' + icon('bi-magic') + ' ' + esc(p.generateLabel || 'Generate') + '</button>'
                + '<button type="button" class="pp-dg-print">' + icon('bi-printer') + ' ' + esc(p.printLabel || 'Print') + '</button>'
                + '<button type="button" class="pp-dg-pdf">' + icon('bi-file-earmark-pdf') + ' ' + esc(p.pdfLabel || 'PDF') + '</button>'
                + (p.emailColumn ? '<button type="button" class="pp-dg-email">' + icon('bi-envelope') + ' ' + esc(p.emailLabel || 'Email') + '</button>' : '')
                + (p.phoneColumn ? '<button type="button" class="pp-dg-sms">' + icon('bi-chat-dots') + ' ' + esc(p.smsLabel || 'SMS') + '</button>' : '') + '</div>'
                + '<div class="pp-dg-doc">' + lhHtml + (tpl ? '<pre class="pp-dg-tpl">' + hi(tpl) + '</pre>' : '<div class="pp-dg-empty">' + esc(p.emptyLabel || 'Pick a record…') + '</div>') + '</div></div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text' },
            { key: 'table', label: 'Records (table or view)', type: 'dbtable', hint: 'The records to generate documents from — point at a reporting view for joined names.' },
            { key: 'labelColumn', label: 'Record label column', type: 'dbcolumn', tableKey: 'table', hint: 'Shown in the picker for each record (e.g. a case number).' }
        ]),
        { title: 'Template', fields: [
            { key: 'template', label: 'Document template', type: 'textarea', hint: 'Insert record fields as {{column}} (e.g. {{plaintiff}}). Special tokens: {{today}} (current date, in the chosen era), {{time}} (the selected time), {{today_bs}} / {{today_ad}}. A {{…_bs}} / {{…_ad}} field is auto-prefixed with वि.सं. / इ.सं.' }
        ]},
        { title: 'Dates & time', fields: [
            { key: 'dateSystem', label: 'Date system', type: 'select', options: [['bs', 'Nepali — वि.सं. (BS)'], ['ad', 'English — इ.सं. (AD)'], ['both', 'Both — वि.सं. (इ.सं.)']], hint: 'How {{today}} is written. Every date also carries its era prefix.' },
            { key: 'bsPrefix', label: 'Nepali (BS) prefix', type: 'text', hint: 'Placed before Bikram Sambat dates.' },
            { key: 'adPrefix', label: 'English (AD) prefix', type: 'text', hint: 'Placed before Gregorian dates.' },
            { key: 'showTime', label: 'Show time picker', type: 'bool', hint: 'Let the user choose the time before generating (fills {{time}}).' },
            { key: 'defaultTime', label: 'Default time', type: 'text', placeholder: '10:00', hint: '24-hour HH:MM. Default 10:00 (10 AM).' },
            { key: 'timeFormat', label: 'Time format', type: 'select', options: [['24', '24-hour (१०:००)'], ['12', '12-hour (बिहान/बेलुका · AM/PM)']], hint: '12-hour writes the Nepali day-part (बिहान/दिउँसो/बेलुका/राति) for BS, or AM/PM for AD.' },
            { key: 'timeLabel', label: 'Time field label', type: 'text' }
        ]},
        { title: 'Letterhead', fields: [
            { key: 'letterheadLogo', label: 'Logo URL', type: 'text', placeholder: '/custom/logo.png', hint: 'Optional letterhead logo. Use a same-origin path (e.g. /custom/…) so it appears in the PDF.' },
            { key: 'letterhead', label: 'Letterhead text', type: 'textarea', hint: 'Organisation / court name + address, shown at the top of the document. Supports {{tokens}} too — e.g. {{court_name}}.' },
            { key: 'letterheadAlign', label: 'Align', type: 'select', options: [['center', 'Center'], ['left', 'Left'], ['right', 'Right']] }
        ]},
        { title: 'Send (Email / SMS)', fields: [
            { key: 'emailColumn', label: 'Email column', type: 'dbcolumn', tableKey: 'table', hint: 'The record’s email address. Set to show an "Email" button — the notice is merged + sent server-side to this address only. Needs Email set up (Misc → Email).' },
            { key: 'phoneColumn', label: 'Phone/SMS column', type: 'dbcolumn', tableKey: 'table', hint: 'The record’s phone number. Set to show an "SMS" button. Needs SMS set up (Misc → SMS).' },
            { key: 'sendSubject', label: 'Email subject', type: 'text', hint: 'Supports {{tokens}} — e.g. "Hearing notice — {{case_number}}".' },
            { key: 'emailLabel', label: '"Email" button', type: 'text' },
            { key: 'smsLabel', label: '"SMS" button', type: 'text' }
        ]},
        { title: 'Labels', fields: [
            { key: 'pickerLabel', label: 'Picker placeholder', type: 'text' },
            { key: 'generateLabel', label: '“Generate” button', type: 'text' },
            { key: 'printLabel', label: '“Print” button', type: 'text' },
            { key: 'downloadLabel', label: '“Text” download button', type: 'text' },
            { key: 'pdfLabel', label: '“PDF” button', type: 'text' },
            { key: 'emptyLabel', label: 'Empty message', type: 'text' }
        ]}, styleGroup() ]; }
    });

    /* Court Fee Calculator — interactive: a claim amount → slab-based court fee. */
    def('fee-calculator', {
        label: 'Fee Calculator', icon: 'bi-calculator', category: 'Data',
        defaults: function () { return { heading: 'Court Fee Calculator', claimLabel: 'Claim amount', feeLabel: 'Court fee', prefix: 'रु ', slabs: '100000:1, 500000:1.5, 0:2', minFee: '', roundTo: '1', note: 'Indicative only — verify with the court fee schedule.' }; },
        render: function (n) {
            var p = n.props || {};
            return '<div class="pp-fc"><div class="pp-fc-head">' + icon('bi-calculator') + '<strong>' + esc(p.heading || 'Court Fee Calculator') + '</strong></div>'
                + '<div class="pp-fc-row"><label>' + esc(p.claimLabel || 'Claim amount') + '</label><input type="number" class="pp-fc-claim" placeholder="0"></div>'
                + '<div class="pp-fc-out"><span>' + esc(p.feeLabel || 'Court fee') + '</span><strong class="pp-fc-fee">' + esc(p.prefix || 'रु ') + '0</strong></div>'
                + (p.note ? '<div class="pp-fc-note">' + esc(p.note) + '</div>' : '') + '</div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text' },
            { key: 'slabs', label: 'Fee slabs', type: 'text', hint: '"threshold:rate%" pairs, ascending. A 0 or blank threshold = the rate above all slabs. e.g. 100000:1, 500000:1.5, 0:2' },
            { key: 'minFee', label: 'Minimum fee', type: 'number' },
            { key: 'roundTo', label: 'Round to', type: 'number', hint: 'Round the fee (e.g. 1, 10).' },
            { key: 'prefix', label: 'Currency prefix', type: 'text' },
            { key: 'claimLabel', label: 'Claim label', type: 'text' },
            { key: 'feeLabel', label: 'Fee label', type: 'text' },
            { key: 'note', label: 'Footnote', type: 'text' }
        ]), styleGroup() ]; }
    });

    /* Limitation (Statute) Calculator — start date + case type → limitation deadline
       with days-remaining + risk. Rules map a type to a period in days. */
    def('limitation-calculator', {
        label: 'Limitation Calculator', icon: 'bi-hourglass-split', category: 'Data',
        defaults: function () { return { heading: 'Limitation Calculator', startLabel: 'Start date (filing/incident)', typeLabel: 'Case type', deadlineLabel: 'Limitation deadline', rules: 'ऋण असुली:730, लिखत बदर:180, default:35', warnDays: 30, bsPrefix: 'वि.सं.', daysLabel: 'days', overdueLabel: 'lapsed', leftLabel: 'left' }; },
        render: function (n) {
            var p = n.props || {};
            var opts = String(p.rules || '').split(',').map(function (r) { return r.split(':')[0].trim(); }).filter(function (k) { return k && k.toLowerCase() !== 'default'; });
            return '<div class="pp-lc"><div class="pp-lc-head">' + icon('bi-hourglass-split') + '<strong>' + esc(p.heading || 'Limitation Calculator') + '</strong></div>'
                + '<div class="pp-lc-row"><label>' + esc(p.startLabel || 'Start date') + '</label><input type="text" class="pp-lc-start pp-ndp-input" placeholder="वि.सं. मिति छान्नुहोस्…" readonly></div>'
                + '<div class="pp-lc-row"><label>' + esc(p.typeLabel || 'Case type') + '</label><select class="pp-lc-type">' + opts.map(function (o) { return '<option>' + esc(o) + '</option>'; }).join('') + '</select></div>'
                + '<div class="pp-lc-out r-upcoming"><span>' + esc(p.deadlineLabel || 'Limitation deadline') + '</span><strong class="pp-lc-deadline">—</strong><span class="pp-lc-left"></span></div></div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text' },
            { key: 'rules', label: 'Limitation rules', type: 'text', hint: '"case type:days" pairs. e.g. ऋण असुली:730, लिखत बदर:180, default:35 (default applies to any other type).' },
            { key: 'warnDays', label: 'Warn within (days)', type: 'number', hint: 'Deadlines this close (or overdue) turn red/amber.' },
            { key: 'bsPrefix', label: 'Nepali (BS) prefix', type: 'text', hint: 'Placed before the computed Bikram Sambat deadline (default वि.सं.).' },
            { key: 'startLabel', label: 'Start-date label', type: 'text' },
            { key: 'typeLabel', label: 'Type label', type: 'text' },
            { key: 'deadlineLabel', label: 'Deadline label', type: 'text' },
            { key: 'daysLabel', label: '“days” word', type: 'text' },
            { key: 'leftLabel', label: '“left” word', type: 'text' },
            { key: 'overdueLabel', label: '“lapsed” word', type: 'text' }
        ]), styleGroup() ]; }
    });

    /* Daily Cause List — pick a date → every hearing that day grouped by court,
       printable. (The bulk / court-clerk companion to the Cause List report.) */
    def('daily-cause-list', {
        label: 'Daily Cause List', icon: 'bi-calendar2-week', category: 'Data',
        defaults: function () { return {
            heading: 'Daily Cause List', window: 45, printLabel: 'Print', emptyLabel: 'No hearings on this date.', dateLabel: 'Date',
            numberColumn: '', partyAColumn: '', partyBColumn: '', vsLabel: 'vs', typeColumn: '', repColumn: '',
            colNo: '#', colCase: 'No.', colParties: 'Parties', colType: 'Type', colRep: 'Rep'
        }; },
        render: function (n) {
            var p = n.props || {};
            var bound = p.table && p.dateColumn && p.groupColumn;
            var body = bound
                ? '<div class="pp-cl-court">' + esc(title(p.groupColumn)) + ' <span>(2)</span></div>'
                    + '<table class="pp-cl-t"><thead><tr><th>' + esc(p.colNo || '#') + '</th>' + (p.numberColumn ? '<th>' + esc(p.colCase || 'No.') + '</th>' : '') + (p.partyAColumn ? '<th>' + esc(p.colParties || 'Parties') + '</th>' : '') + (p.typeColumn ? '<th>' + esc(p.colType || 'Type') + '</th>' : '') + (p.repColumn ? '<th>' + esc(p.colRep || 'Rep') + '</th>' : '') + '</tr></thead>'
                    + '<tbody><tr><td>1</td>' + (p.numberColumn ? '<td>0-CV-001</td>' : '') + (p.partyAColumn ? '<td>Sample vs Sample</td>' : '') + (p.typeColumn ? '<td>—</td>' : '') + (p.repColumn ? '<td>—</td>' : '') + '</tr></tbody></table>'
                : '<div class="pp-cl-empty">Bind a table, a date column and a group-by column →</div>';
            return '<div class="pp-cl pp-dcl"><div class="pp-cl-bar"><input type="date" class="pp-dcl-date"><button type="button" class="pp-cl-print">' + icon('bi-printer') + ' ' + esc(p.printLabel || 'Print') + '</button></div>'
                + '<div class="pp-cl-doc"><div class="pp-cl-h"><strong>' + esc(p.heading || 'Daily Cause List') + '</strong><span class="pp-dcl-shown">' + new Date().toISOString().slice(0, 10) + '</span></div>' + body + '</div></div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text' },
            { key: 'table', label: 'Source (table or view)', type: 'dbtable' },
            { key: 'dateColumn', label: 'Hearing-date column', type: 'dbcolumn', tableKey: 'table' },
            { key: 'groupColumn', label: 'Group by (court)', type: 'dbcolumn', tableKey: 'table' },
            { key: 'window', label: 'Data window (± days)', type: 'number', hint: 'How many days around today to load, so any date in that range prints instantly.' },
            { key: 'dateLabel', label: 'Date label', type: 'text' },
            { key: 'printLabel', label: 'Print label', type: 'text' },
            { key: 'emptyLabel', label: 'Empty message', type: 'text' }
        ]),
        { title: 'Columns & headers', fields: [
            { key: 'colNo', label: 'Serial header', type: 'text' },
            { key: 'numberColumn', label: 'Reference column', type: 'dbcolumn', tableKey: 'table' },
            { key: 'colCase', label: 'Reference header', type: 'text' },
            { key: 'partyAColumn', label: 'Party A column', type: 'dbcolumn', tableKey: 'table' },
            { key: 'partyBColumn', label: 'Party B column', type: 'dbcolumn', tableKey: 'table' },
            { key: 'vsLabel', label: '“versus” word', type: 'text' },
            { key: 'colParties', label: 'Parties header', type: 'text' },
            { key: 'typeColumn', label: 'Type column', type: 'dbcolumn', tableKey: 'table' },
            { key: 'colType', label: 'Type header', type: 'text' },
            { key: 'repColumn', label: 'Rep column', type: 'dbcolumn', tableKey: 'table' },
            { key: 'colRep', label: 'Rep header', type: 'text' }
        ]}, styleGroup() ]; }
    });

    /* Activity Timeline — a chronological vertical timeline of a table's records. */
    def('activity-timeline', {
        label: 'Activity Timeline', icon: 'bi-clock-history', category: 'Data',
        defaults: function () { return { heading: 'Recent Activity', limit: 12, emptyLabel: 'No activity yet.', dateSystem: 'bs', bsPrefix: 'वि.सं.', adPrefix: 'इ.सं.' }; },
        render: function (n) {
            var p = n.props || {};
            if (!(p.table && p.dateColumn)) return '<div class="pp-tl"><div class="pp-tl-head">' + icon('bi-clock-history') + '<strong>' + esc(p.heading || 'Recent Activity') + '</strong></div><div class="pp-tl-empty">Bind a table + a date column →</div></div>';
            var sample = [['2026-08-16', 'Hearing scheduled', 'District Court'], ['2026-08-12', 'Document filed', 'Statement of claim'], ['2026-08-04', 'Case registered', 'Debt recovery']];
            var items = sample.map(function (s) { return '<div class="pp-tl-item"><span class="pp-tl-dot"></span><div class="pp-tl-body"><div class="pp-tl-date">' + esc(s[0]) + '</div><div class="pp-tl-title">' + esc(s[1]) + '</div><div class="pp-tl-sub">' + esc(s[2]) + '</div></div></div>'; }).join('');
            return '<div class="pp-tl"><div class="pp-tl-head">' + icon('bi-clock-history') + '<strong>' + esc(p.heading || 'Recent Activity') + '</strong></div><div class="pp-tl-list">' + items + '</div></div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text' },
            { key: 'table', label: 'Table or view', type: 'dbtable' },
            { key: 'dateColumn', label: 'Date column', type: 'dbcolumn', tableKey: 'table', hint: 'Events are ordered by this, newest first.' },
            { key: 'titleColumn', label: 'Title column', type: 'dbcolumn', tableKey: 'table' },
            { key: 'subtitleColumn', label: 'Subtitle column', type: 'dbcolumn', tableKey: 'table', hint: 'Optional second line.' },
            { key: 'where', label: 'Filter (WHERE)', type: 'text', hint: 'Optional. Supports :userid, :today…' },
            { key: 'limit', label: 'How many', type: 'number' },
            { key: 'keyColumn', label: 'Record key column', type: 'dbcolumn', tableKey: 'table' },
            { key: 'linkTable', label: '“Open” → table', type: 'dbtable', hint: 'Each item links to this table’s view page. Blank = no link.' },
            { key: 'dateSystem', label: 'Date display', type: 'select', options: [['bs', 'Nepali (BS, Devanagari)'], ['ad', 'English (AD)'], ['both', 'Both — BS (AD)']], hint: 'AD date columns are converted to Bikram Sambat automatically.' },
            { key: 'bsPrefix', label: 'Nepali (BS) prefix', type: 'text' },
            { key: 'adPrefix', label: 'English (AD) prefix', type: 'text' },
            { key: 'emptyLabel', label: 'Empty message', type: 'text' }
        ]), styleGroup() ]; }
    });

    /* Client Portal Link — for a record, a shareable public status link (signed
       token, no login) the party can open. Public page = generated portal.php. */
    def('portal-link', {
        label: 'Portal Link', icon: 'bi-share', category: 'Data',
        defaults: function () { return { heading: 'Client Status Link', pickerLabel: 'Choose a record…', copyLabel: 'Copy link', openLabel: 'Open', hint: 'Share this link with the party — they can view the case status without logging in.' }; },
        render: function (n) {
            var p = n.props || {};
            return '<div class="pp-pl"><div class="pp-pl-head">' + icon('bi-share') + '<strong>' + esc(p.heading || 'Client Status Link') + '</strong></div>'
                + '<div class="pp-pl-bar"><select class="pp-pl-pick"><option>' + esc(p.pickerLabel || 'Choose a record…') + '</option>' + (p.table ? '<option>' + esc(title(p.table)) + ' record…</option>' : '') + '</select>'
                + '<button type="button" class="pp-pl-copy">' + icon('bi-clipboard') + ' ' + esc(p.copyLabel || 'Copy link') + '</button></div>'
                + '<div class="pp-pl-out"><input type="text" class="pp-pl-url" readonly placeholder="…/portal.php?c=…&t=…"></div>'
                + (p.hint ? '<div class="pp-pl-hint">' + esc(p.hint) + '</div>' : '') + '</div>';
        },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text' },
            { key: 'table', label: 'Records table', type: 'dbtable', hint: 'The records to share a status link for.' },
            { key: 'labelColumn', label: 'Record label column', type: 'dbcolumn', tableKey: 'table' },
            { key: 'titleColumn', label: 'Portal title column', type: 'dbcolumn', tableKey: 'table', hint: 'Heading on the public page (e.g. case number).' },
            { key: 'publicColumns', label: 'Public fields', type: 'text', hint: '"column:Label" pairs shown on the public page. Keep it to non-sensitive fields only. e.g. status_label:Status, next_hearing_date_bs:Next hearing' },
            { key: 'pickerLabel', label: 'Picker placeholder', type: 'text' },
            { key: 'copyLabel', label: '“Copy” label', type: 'text' },
            { key: 'hint', label: 'Footnote', type: 'text' }
        ]), styleGroup() ]; }
    });

    /* ============================================================
       Backend component catalogue — real renders for the high-value
       components, then a polished generic render for the remainder.
       Every catalogue type becomes live & droppable on any page.
       ============================================================ */
    function txt(n, key, dflt) { return esc((n.props && n.props[key]) || dflt || ''); }
    // Shared "custom style" inline-style builder (Alert, Toast): solid/gradient
    // background, font, text/border/radius, + --box-title / --box-icon vars.
    function boxStyle(p) {
        var s = '';
        if (p.gradient && (p.bg || p.bg2)) s += 'background:linear-gradient(' + (p.gradAngle || '135deg') + ',' + (p.bg || '#6366f1') + ',' + (p.bg2 || p.bg || '#8b5cf6') + ');';
        else if (p.bg) s += 'background:' + p.bg + ';';
        if (p.font) s += 'font-family:' + p.font + ';';
        if (p.textColor) s += 'color:' + p.textColor + ';';
        if (p.borderColor) s += 'border-color:' + p.borderColor + ';';
        if (p.radius) s += 'border-radius:' + p.radius + ';';
        if (p.titleColor) s += '--box-title:' + p.titleColor + ';';
        if (p.iconColor) s += '--box-icon:' + p.iconColor + ';';
        return s;
    }
    // Reusable "Custom style" schema group for Alert / Toast.
    function customStyleGroup() {
        return { title: 'Custom style', fields: [
            { key: 'bg', label: 'Background', type: 'color', hint: 'Overrides the tone colour.' },
            { key: 'gradient', label: 'Gradient', type: 'bool', rerender: true },
            { key: 'bg2', label: 'Gradient to', type: 'color', when: function (p) { return !!p.gradient; } },
            { key: 'gradAngle', label: 'Angle', type: 'text', when: function (p) { return !!p.gradient; }, placeholder: '135deg' },
            { key: 'font', label: 'Font', type: 'font' },
            { key: 'textColor', label: 'Text colour', type: 'color' },
            { key: 'titleColor', label: 'Title colour', type: 'color' },
            { key: 'iconColor', label: 'Icon colour', type: 'color' },
            { key: 'borderColor', label: 'Border colour', type: 'color' },
            { key: 'radius', label: 'Corner radius', type: 'text', placeholder: 'e.g. 12px' }
        ]};
    }
    var sampleRows = ['bi-record-circle', 'bi-record-circle', 'bi-record-circle'];

    // Layout containers (children flow into a responsive grid drop-zone)
    def('single_column', { label: 'Single Column', icon: 'bi-layout-text-window', category: 'Layouts', container: true,
        defaults: function () { return {}; }, schema: function () { return [ generalGroup([]), styleGroup() ]; },
        render: function (n, ctx) { return ctx.renderChildren(n, 'ppd-lay ppd-lay-1'); } });
    def('two_column', { label: 'Two Columns', icon: 'bi-layout-split', category: 'Layouts', container: true,
        defaults: function () { return {}; }, schema: function () { return [ generalGroup([]), styleGroup() ]; },
        render: function (n, ctx) { return ctx.renderChildren(n, 'ppd-lay ppd-lay-2'); } });
    def('kpi_grid', { label: 'KPI Grid', icon: 'bi-grid-3x3-gap', category: 'Layouts', container: true,
        defaults: function () { return {}; }, schema: function () { return [ generalGroup([]), styleGroup() ]; },
        render: function (n, ctx) { return ctx.renderChildren(n, 'ppd-lay ppd-lay-kpi'); } });
    def('analytics_grid', { label: 'Analytics Grid', icon: 'bi-bar-chart-line', category: 'Layouts', container: true,
        defaults: function () { return {}; }, schema: function () { return [ generalGroup([]), styleGroup() ]; },
        render: function (n, ctx) { return ctx.renderChildren(n, 'ppd-lay ppd-lay-analytics'); } });
    def('column', { label: 'Column', icon: 'bi-layout-sidebar', category: 'Structure', container: true,
        defaults: function () { return {}; }, schema: function () { return [ generalGroup([]), styleGroup() ]; },
        render: function (n, ctx) { return ctx.renderChildren(n, 'ppd-lay ppd-lay-col'); } });
    def('accordion', { label: 'Accordion', icon: 'bi-list-nested', category: 'Structure', container: true,
        defaults: function () { return { heading: 'Section title', open: true }; },
        schema: function () { return [ generalGroup([{ key: 'heading', label: 'Heading', type: 'text' }, { key: 'open', label: 'Open', type: 'bool' }]), styleGroup() ]; },
        render: function (n, ctx) { return '<div class="ppd-accordion"><div class="ppd-accordion-h">' + icon(n.props.open ? 'bi-chevron-down' : 'bi-chevron-right') + '<span>' + txt(n, 'heading', 'Section title') + '</span></div>' + ctx.renderChildren(n, 'ppd-accordion-b') + '</div>'; } });
    def('modal', { label: 'Modal', icon: 'bi-window', category: 'Structure', container: true,
        defaults: function () { return { heading: 'Modal title' }; },
        schema: function () { return [ generalGroup([{ key: 'heading', label: 'Heading', type: 'text' }]), styleGroup() ]; },
        render: function (n, ctx) { return '<div class="ppd-modal"><div class="ppd-modal-h"><strong>' + txt(n, 'heading', 'Modal title') + '</strong>' + icon('bi-x-lg') + '</div>' + ctx.renderChildren(n, 'ppd-modal-b') + '<div class="ppd-modal-f">' + btn({ icon: 'bi-x', label: 'Cancel', displayMode: 'label' }, '') + btn({ icon: 'bi-check-lg', label: 'Confirm', displayMode: 'icon_label', variant: 'primary' }, '') + '</div></div>'; } });
    def('offcanvas', { label: 'Offcanvas', icon: 'bi-layout-sidebar-inset', category: 'Navigation', container: true,
        defaults: function () { return { triggerLabel: 'Open panel', triggerIcon: 'bi-layout-sidebar-inset', triggerVariant: 'primary', heading: 'Panel', side: 'end', width: '', backdrop: true, scroll: false }; },
        schema: function () { return [ generalGroup([
            { key: 'triggerLabel', label: 'Button label', type: 'text', hint: 'Text on the button that opens the panel.' },
            { key: 'triggerIcon', label: 'Button icon', type: 'icon', hint: 'Icon on the trigger button (clear for none).' },
            { key: 'triggerVariant', label: 'Button style', type: 'select', options: ['default', 'primary', 'danger'], hint: 'Trigger button colour.' },
            { key: 'heading', label: 'Panel heading', type: 'text', hint: 'Title shown inside the slide-out panel.' },
            { key: 'side', label: 'Opens from', type: 'select', options: ['start', 'end', 'top', 'bottom'], hint: 'start = left · end = right · top · bottom.' },
            { key: 'width', label: 'Width / Height', type: 'text', placeholder: '380px', hint: 'Panel width (left/right) or height (top/bottom). Blank = default.' },
            { key: 'backdrop', label: 'Dim background', type: 'bool', def: true, hint: 'Show a dark backdrop behind the open panel.' },
            { key: 'scroll', label: 'Allow body scroll', type: 'bool', hint: 'Keep the page scrollable while the panel is open.' }
        ]), styleGroup() ]; },
        render: function (n, ctx) {
            var p = n.props || {};
            var v = (p.triggerVariant === 'primary' || p.triggerVariant === 'danger') ? ' ' + p.triggerVariant : '';
            var trigger = '<button class="ppd-bg-btn' + v + '" type="button">' + (p.triggerIcon ? icon(p.triggerIcon) : '') + '<span>' + txt(n, 'triggerLabel', 'Open panel') + '</span></button>';
            var head = '<div class="ppd-oc-head"><strong>' + txt(n, 'heading', 'Panel') + '</strong>' + icon('bi-x-lg') + '</div>';
            var panel = '<div class="ppd-oc-panel ppd-oc-side-' + (p.side || 'end') + '">' + head + ctx.renderChildren(n, 'ppd-oc-body') + '</div>';
            return '<div class="ppd-oc"><div class="ppd-oc-preview-label">' + icon('bi-layout-sidebar-inset') + ' Offcanvas (preview — panel slides out at runtime)</div>' + trigger + panel + '</div>';
        } });

    // Feedback / UI widgets
    def('status-badge', { label: 'Status Badge', icon: 'bi-patch-check', category: 'Feedback',
        defaults: function () { return { source: 'manual', text: 'Active', tone: 'ok', variant: 'soft', size: 'md', shape: 'pill', showDot: true }; },
        schema: function () {
            var dyn = function (p) { return p.source === 'dynamic'; };
            return [ generalGroup([
                { key: 'source', label: 'Text from', type: 'radiogroup', options: [['manual', 'Manual'], ['dynamic', 'SQL']], rerender: true },
                { key: 'text', label: 'Text', type: 'text', when: function (p) { return p.source !== 'dynamic'; } },
                { key: 'query', label: 'Text SQL', type: 'text', full: true, when: dyn, placeholder: 'SELECT status FROM orders WHERE id = :userid', hint: 'Returns the badge text. Supports SQL variables.' },
                { key: 'tone', label: 'Tone', type: 'select', options: [['ok', 'Success'], ['warn', 'Warning'], ['danger', 'Danger'], ['info', 'Info'], ['primary', 'Primary'], ['muted', 'Muted']] }
            ]),
            { title: 'Style', fields: [
                { key: 'variant', label: 'Variant', type: 'select', options: [['soft', 'Soft'], ['solid', 'Solid'], ['outline', 'Outline']] },
                { key: 'size', label: 'Size', type: 'select', options: [['sm', 'Small'], ['md', 'Medium'], ['lg', 'Large']] },
                { key: 'shape', label: 'Shape', type: 'select', options: [['pill', 'Pill'], ['square', 'Rounded']] },
                { key: 'icon', label: 'Icon', type: 'icon', hint: 'Optional — replaces the dot.' },
                { key: 'showDot', label: 'Show dot', type: 'bool' },
                { key: 'pulse', label: 'Pulsing dot', type: 'bool', hint: 'Animated — good for “live” / “online”.' }
            ]},
            { title: 'Custom colours', fields: [
                { key: 'color', label: 'Text', type: 'color' },
                { key: 'bgColor', label: 'Background', type: 'color' },
                { key: 'borderColor', label: 'Border', type: 'color' }
            ]},
            styleGroup() ];
        },
        render: function (n) {
            var p = n.props || {};
            var tone = p.tone || 'ok';
            var cls = 'ppd-badge tone-' + tone + (p.variant === 'solid' ? ' bdg-solid' : p.variant === 'outline' ? ' bdg-outline' : '')
                + (p.size === 'sm' ? ' bdg-sm' : p.size === 'lg' ? ' bdg-lg' : '') + (p.shape === 'square' ? ' bdg-square' : '');
            var sv = (p.color ? 'color:' + p.color + ';' : '') + (p.bgColor ? 'background:' + p.bgColor + ';' : '') + (p.borderColor ? 'border-color:' + p.borderColor + ';' : '');
            var lead = p.icon ? icon(p.icon) : (p.showDot !== false ? '<span class="dot' + (p.pulse ? ' pulse' : '') + '"></span>' : '');
            var t = p.source === 'dynamic' ? (p.query ? 'SQL value' : 'Status') : txt(n, 'text', 'Active');
            return '<span class="' + cls + '"' + (sv ? ' style="' + esc(sv) + '"' : '') + '>' + lead + '<span' + elAttrs(n, 'text') + '>' + t + '</span></span>';
        } });
    def('toast', { label: 'Toast', icon: 'bi-bell', category: 'Feedback',
        defaults: function () { return { title: 'Saved', text: 'Your changes were saved.', tone: 'ok', position: 'tr', duration: 4000, closable: true }; },
        schema: function () { return [ generalGroup([
            { key: 'title', label: 'Title', type: 'text' },
            { key: 'text', label: 'Text', type: 'text' },
            { key: 'tone', label: 'Tone', type: 'select', options: [['ok', 'Success'], ['warn', 'Warning'], ['danger', 'Danger'], ['info', 'Info']] }
        ]),
        { title: 'Behaviour', fields: [
            { key: 'position', label: 'Position', type: 'select', options: [['tr', 'Top right'], ['tc', 'Top center'], ['tl', 'Top left'], ['br', 'Bottom right'], ['bc', 'Bottom center'], ['bl', 'Bottom left']], hint: 'Where it pops in the live app.' },
            { key: 'duration', label: 'Auto-hide (ms)', type: 'number', placeholder: '4000', hint: '0 = stays until closed.' },
            { key: 'closable', label: 'Close button', type: 'bool' },
            { key: 'icon', label: 'Icon', type: 'icon', hint: 'Blank = auto by tone.' }
        ]},
        customStyleGroup(),
        styleGroup() ]; },
        render: function (n) {
            var p = n.props || {}, tone = p.tone || 'ok';
            var autoIco = ({ ok: 'bi-check-circle', warn: 'bi-exclamation-triangle', danger: 'bi-x-octagon', info: 'bi-info-circle' })[tone] || 'bi-check-circle';
            var ico = icon(p.icon || autoIco);
            var cs = boxStyle(p);
            var close = p.closable !== false ? '<button type="button" class="ppd-toast-close">' + icon('bi-x') + '</button>' : '';
            var text = txt(n, 'text', 'Your changes were saved.');
            return '<div class="ppd-toast tone-' + esc(tone) + '"' + (cs ? ' style="' + esc(cs) + '"' : '') + '>' + ico + '<div><strong' + elAttrs(n, 'title') + '>' + txt(n, 'title', 'Saved') + '</strong>' + (text ? '<small' + elAttrs(n, 'text') + '>' + text + '</small>' : '') + '</div>' + close + '</div>';
        } });
    def('progress', { label: 'Progress', icon: 'bi-bar-chart-steps', category: 'Feedback',
        defaults: function () { return { variant: 'bar', source: 'manual', value: 65, label: '', showPercent: true, size: 'md', rounded: true }; },
        schema: function () {
            var dyn = function (p) { return p.source === 'dynamic'; };
            var bar = function (p) { return (p.variant || 'bar') !== 'ring'; };
            var ring = function (p) { return p.variant === 'ring'; };
            return [ generalGroup([
                { key: 'variant', label: 'Style', type: 'radiogroup', options: [['bar', 'Bar'], ['ring', 'Ring']], rerender: true },
                { key: 'source', label: 'Value from', type: 'radiogroup', options: [['manual', 'Manual'], ['dynamic', 'SQL']], rerender: true, hint: 'Manual = a fixed %. SQL = a query result (÷ Total if set).' },
                { key: 'value', label: 'Percent', type: 'number', when: function (p) { return p.source !== 'dynamic'; } },
                { key: 'query', label: 'Value SQL', type: 'text', full: true, when: dyn, placeholder: 'SELECT COUNT(*) FROM tasks WHERE done=1', hint: 'Returns a number. Supports SQL variables.' },
                { key: 'total', label: 'Total (for %)', type: 'number', when: dyn, hint: 'Percent = value ÷ total × 100. Blank = the query already returns a %.' },
                { key: 'label', label: 'Label', type: 'text' },
                { key: 'showPercent', label: 'Show percent', type: 'bool' }
            ]),
            { title: 'Style', fields: [
                { key: 'thresholds', label: 'Auto colour by value', type: 'bool', hint: '<34% red · <67% amber · else green.' },
                { key: 'color', label: 'Fill colour', type: 'color' },
                { key: 'trackColor', label: 'Track colour', type: 'color' },
                { key: 'size', label: 'Bar size', type: 'select', options: [['sm', 'Small'], ['md', 'Medium'], ['lg', 'Large']], when: bar },
                { key: 'rounded', label: 'Rounded', type: 'bool', when: bar },
                { key: 'striped', label: 'Striped', type: 'bool', when: bar },
                { key: 'animated', label: 'Animated stripes', type: 'bool', when: bar },
                { key: 'target', label: 'Target marker %', type: 'number', when: bar, hint: 'A tick at this % on the bar.' },
                { key: 'ringSize', label: 'Ring size', type: 'text', when: ring, placeholder: 'e.g. 84px' },
                { key: 'thickness', label: 'Ring thickness', type: 'text', when: ring, placeholder: 'e.g. 9px' }
            ]},
            styleGroup() ];
        },
        render: function (n) {
            var p = n.props || {};
            var isRing = p.variant === 'ring';
            var v = p.source === 'dynamic' ? 65 : Math.max(0, Math.min(100, parseInt(p.value, 10) || 0));
            var showPct = p.showPercent !== false;
            var tone = p.thresholds ? (v < 34 ? 'danger' : v < 67 ? 'warning' : 'success') : '';
            var fill = tone ? ('var(--pp-' + tone + ')') : (p.color || '');
            if (isRing) {
                var vars = '--ring-p:' + v + ';' + (p.ringSize ? '--ring-sz:' + p.ringSize + ';' : '') + (p.thickness ? '--ring-th:' + p.thickness + ';' : '') + (fill ? '--ring-fill:' + fill + ';' : '') + (p.trackColor ? '--ring-track:' + p.trackColor + ';' : '');
                var center = showPct ? '<span class="ppd-progress-ring-txt">' + v + '%</span>' : '';
                var rlbl = p.label ? '<div class="ppd-progress-ring-lbl">' + esc(p.label) + '</div>' : '';
                return '<div class="ppd-progress-ring-wrap"><div class="ppd-progress-ring" style="' + esc(vars) + '">' + center + '</div>' + rlbl + '</div>';
            }
            var barCls = 'ppd-progress-bar' + (tone ? ' tone-' + tone : '') + (p.striped ? ' is-striped' : '') + (p.animated ? ' is-animated' : '');
            var barStyle = 'width:' + v + '%' + ((!tone && p.color) ? ';background-color:' + p.color : '');
            var trackCls = 'ppd-progress pp-prog-' + (['sm', 'md', 'lg'].indexOf(p.size) > -1 ? p.size : 'md') + (p.rounded === false ? ' pp-prog-square' : '');
            var trackStyle = p.trackColor ? ' style="background:' + esc(p.trackColor) + '"' : '';
            var tgt = (p.target !== '' && p.target != null) ? '<span class="ppd-progtgt" style="left:' + Math.max(0, Math.min(100, parseInt(p.target, 10) || 0)) + '%"></span>' : '';
            var lblRow = (p.label || showPct) ? '<div class="ppd-progress-lbl"><span>' + esc(p.label || '') + '</span>' + (showPct ? '<span>' + v + '%</span>' : '') + '</div>' : '';
            return '<div class="ppd-progress-wrap">' + lblRow + '<div class="' + trackCls + '"' + trackStyle + '>' + tgt + '<div class="' + barCls + '" style="' + barStyle + '"></div></div></div>';
        } });
    def('progress-card', { label: 'Progress Card', icon: 'bi-speedometer2', category: 'Dashboard Widgets',
        defaults: function () { return { label: 'Onboarding', source: 'manual', value: 72, hint: '18 of 25 steps', size: 'md', showPercent: true, rounded: true }; },
        schema: function () { return [ generalGroup([
            { key: 'label', label: 'Label', type: 'text', hint: 'The title shown above the bar (e.g. “Onboarding”).' },
            { key: 'source', label: 'Percent from', type: 'radiogroup', options: ['manual', 'dynamic'], rerender: true,
              hint: 'Manual = you type a fixed %. Dynamic = a SQL query reads the % live from the database.' },
            { key: 'value', label: 'Percent', type: 'number', when: { key: 'source', in: ['manual', ''] },
              hint: 'The fill level, 0–100. Used when the source is Manual.' },
            { key: 'query', label: 'SQL (returns a number)', type: 'text', full: true, when: { key: 'source', in: ['dynamic'] },
              placeholder: "SELECT ROUND(SUM(done)/COUNT(*)*100) FROM tasks WHERE user_id = :userid",
              hint: 'A SELECT returning ONE number. Return the % directly (0–100), or a count and set Total below. Runs once when the page loads; supports SQL variables.',
              note: 'Return the percent directly, or a count and set <b>Total</b> below (→ N of Total). Supports SQL variables — <code>:userid</code>, <code>:today</code>, <code>:session.key</code>, <code>:field.name</code>.' },
            { key: 'total', label: 'Total (blank = query is the %)', type: 'number', when: { key: 'source', in: ['dynamic'] },
              hint: 'If set, the query result is treated as the current count and the % = current ÷ total. Leave blank if the query already returns a %.' },
            { key: 'autoHint', label: 'Auto hint “N of Total”', type: 'bool', when: { key: 'source', in: ['dynamic'] },
              hint: 'Builds the small hint text automatically as “<current> of <total> <unit>” (needs Total set).' },
            { key: 'hintUnit', label: 'Hint unit (e.g. steps)', type: 'text', when: { key: 'source', in: ['dynamic'] },
              hint: 'The word after the numbers in the auto hint, e.g. “steps”, “records”, “tasks”.' },
            { key: 'hint', label: 'Hint', type: 'text', hint: 'Small grey text under the bar. Ignored when Auto hint is on.' }
        ]), { title: 'Bar', fields: [
            { key: 'showPercent', label: 'Show percent label', type: 'bool', hint: 'Show the “72%” number in the top-right corner.' },
            { key: 'color', label: 'Bar color', type: 'color', hint: 'Fill colour of the bar. Blank = the theme’s primary colour. Ignored when “Color by value” is on.' },
            { key: 'thresholds', label: 'Color by value (red / amber / green)', type: 'bool',
              hint: 'Auto-colour the bar by the %: red under 34, amber 34–66, green 67+. Overrides Bar color.' },
            { key: 'size', label: 'Thickness', type: 'select', options: ['sm', 'md', 'lg'], hint: 'How thick the bar is: sm ≈ 5px, md ≈ 9px, lg ≈ 14px.' },
            { key: 'striped', label: 'Striped', type: 'bool', hint: 'Add diagonal stripes to the fill.' },
            { key: 'animated', label: 'Animated stripes', type: 'bool', hint: 'Make the stripes scroll (needs Striped on).' },
            { key: 'rounded', label: 'Rounded ends', type: 'bool', hint: 'Rounded (pill) ends vs square ends.' },
            { key: 'target', label: 'Target marker (%)', type: 'number', hint: 'Draws a vertical goal line at this % (e.g. 80). Blank = no marker.' }
        ] }, styleGroup() ]; },
        render: function (n) {
            var p = n.props || {};
            var v = Math.max(0, Math.min(100, parseInt(p.value, 10) || 0));   // canvas preview uses the manual %
            var dyn = p.source === 'dynamic';
            var tone = p.thresholds ? (v < 34 ? 'danger' : v < 67 ? 'warning' : 'success') : '';
            var barCls = 'ppd-progress-bar' + (tone ? ' tone-' + tone : '') + (p.striped ? ' is-striped' : '') + (p.animated ? ' is-animated' : '');
            var barStyle = 'width:' + v + '%' + (!tone && p.color ? ';background-color:' + esc(p.color) : '');
            var trackCls = 'ppd-progress pp-prog-' + esc(p.size || 'md') + (p.rounded === false ? ' pp-prog-square' : '');
            var tp = parseInt(p.target, 10);
            var tgt = (p.target != null && p.target !== '' && !isNaN(tp)) ? '<span class="ppd-progtgt" style="left:' + Math.max(0, Math.min(100, tp)) + '%"></span>' : '';
            var pctLabel = (p.showPercent === false) ? '' : '<span' + elAttrs(n, 'value') + '>' + v + '%</span>';
            var hint = (dyn && p.autoHint) ? ('&hellip; ' + esc(p.hintUnit || '')) : txt(n, 'hint', '');
            var dynBadge = dyn ? ' <i class="bi bi-database ppd-dynbadge" title="Dynamic — from SQL"></i>' : '';
            return '<div class="ppd-widget"><div class="ppd-widget-h"><strong' + elAttrs(n, 'label') + '>' + txt(n, 'label', 'Progress') + dynBadge + '</strong>' + pctLabel + '</div>'
                + '<div class="' + trackCls + '">' + tgt + '<div class="' + barCls + '" style="' + barStyle + '"></div></div>'
                + '<small' + elAttrs(n, 'hint', 'ppd-widget-hint') + '>' + hint + '</small></div>';
        } });
    def('rating', { label: 'Rating', icon: 'bi-star-half', category: 'Feedback',
        defaults: function () { return { source: 'manual', value: 4.5, max: 5, symbol: 'star', allowHalf: true, showValue: false }; },
        schema: function () {
            var dyn = function (p) { return p.source === 'dynamic'; };
            return [ generalGroup([
                { key: 'source', label: 'Value from', type: 'radiogroup', options: [['manual', 'Manual'], ['dynamic', 'SQL']], rerender: true },
                { key: 'value', label: 'Value', type: 'number', when: function (p) { return p.source !== 'dynamic'; }, hint: 'Supports halves, e.g. 4.5.' },
                { key: 'query', label: 'Value SQL', type: 'text', full: true, when: dyn, placeholder: 'SELECT AVG(stars) FROM reviews', hint: 'Returns the rating number. Supports SQL variables (:userid…).' },
                { key: 'max', label: 'Out of', type: 'number' },
                { key: 'label', label: 'Label', type: 'text' }
            ]),
            { title: 'Style', fields: [
                { key: 'symbol', label: 'Symbol', type: 'select', options: [['star', 'Star'], ['heart', 'Heart'], ['circle', 'Circle'], ['square', 'Square']] },
                { key: 'allowHalf', label: 'Allow half', type: 'bool' },
                { key: 'color', label: 'Filled colour', type: 'color' },
                { key: 'emptyColor', label: 'Empty colour', type: 'color' },
                { key: 'size', label: 'Size', type: 'text', placeholder: 'e.g. 18px' },
                { key: 'showValue', label: 'Show value', type: 'bool' },
                { key: 'valueSuffix', label: 'Value suffix', type: 'text', when: function (p) { return !!p.showValue; }, placeholder: 'e.g. /5' }
            ]},
            styleGroup() ];
        },
        render: function (n) {
            var p = n.props || {};
            var SYM = { star: ['bi-star-fill', 'bi-star-half', 'bi-star'], heart: ['bi-heart-fill', 'bi-heart-half', 'bi-heart'], circle: ['bi-circle-fill', 'bi-circle-half', 'bi-circle'], square: ['bi-square-fill', 'bi-square-half', 'bi-square'] };
            var sym = SYM[p.symbol] || SYM.star, half = p.allowHalf !== false;
            var mx = Math.max(1, Math.min(20, parseInt(p.max, 10) || 5));
            var v = p.source === 'dynamic' ? 4.5 : (parseFloat(p.value) || 0);
            v = Math.max(0, Math.min(mx, v));
            var s = '';
            for (var i = 1; i <= mx; i++) {
                var ic, cls;
                if (v >= i) { ic = sym[0]; cls = 'is-on'; }
                else if (half && v >= i - 0.5) { ic = sym[1]; cls = 'is-on'; }
                else { ic = sym[2]; cls = 'is-off'; }
                s += '<i class="' + cls + ' bi ' + ic + '"></i>';
            }
            var vars = (p.color ? '--rate-color:' + p.color + ';' : '') + (p.emptyColor ? '--rate-empty:' + p.emptyColor + ';' : '') + (p.size ? '--rate-size:' + p.size + ';' : '');
            var lbl = p.label ? '<span class="ppd-rating-lbl">' + esc(p.label) + '</span>' : '';
            var val = p.showValue ? '<span class="ppd-rating-val">' + esc(String(v) + (p.valueSuffix || '')) + '</span>' : '';
            return '<span class="ppd-rating"' + (vars ? ' style="' + esc(vars) + '"' : '') + '>' + lbl + s + val + '</span>';
        } });
    def('avatar', { label: 'Avatar', icon: 'bi-person-circle', category: 'Feedback',
        defaults: function () { return { source: 'manual', avatarType: 'initials', name: 'Jane Doe', subtitle: 'Administrator', ico: 'bi-person', shape: 'circle', size: 'md', showText: true, status: 'none' }; },
        schema: function () {
            var dyn = function (p) { return p.source === 'dynamic'; };
            var man = function (p) { return p.source !== 'dynamic'; };
            return [ generalGroup([
                { key: 'source', label: 'Source', type: 'radiogroup', options: [['manual', 'Manual'], ['dynamic', 'From table']], rerender: true, hint: 'Manual = type the details. From table = pull a record’s name/photo live.' },
                { key: 'avatarType', label: 'Show', type: 'select', options: [['image', 'Photo'], ['initials', 'Initials'], ['icon', 'Icon']], rerender: true, when: man, hint: 'Photo from a URL, initials from the name, or an icon.' },
                { key: 'name', label: 'Name', type: 'text', when: man },
                { key: 'subtitle', label: 'Subtitle', type: 'text', when: man },
                { key: 'imageUrl', label: 'Photo URL', type: 'text', when: function (p) { return p.source !== 'dynamic' && p.avatarType === 'image'; }, placeholder: 'https://…/photo.jpg' },
                { key: 'ico', label: 'Icon', type: 'icon', when: function (p) { return p.source !== 'dynamic' && (p.avatarType || 'initials') === 'icon'; } },
                { key: 'table', label: 'Table', type: 'dbtable', when: dyn },
                { key: 'nameColumn', label: 'Name column', type: 'dbcolumn', tableKey: 'table', when: dyn },
                { key: 'subtitleColumn', label: 'Subtitle column', type: 'dbcolumn', tableKey: 'table', when: dyn },
                { key: 'imageColumn', label: 'Photo URL column', type: 'dbcolumn', tableKey: 'table', when: dyn, hint: 'Optional — a column holding a photo URL. Blank = initials from the name.' },
                { key: 'where', label: 'Filter (WHERE)', type: 'text', when: dyn, hint: 'Which record — supports :userid, :today… e.g. id = :userid for the signed-in user.' }
            ]),
            { title: 'Appearance', fields: [
                { key: 'shape', label: 'Shape', type: 'select', options: [['circle', 'Circle'], ['rounded', 'Rounded'], ['square', 'Square']] },
                { key: 'size', label: 'Size', type: 'select', options: [['sm', 'Small'], ['md', 'Medium'], ['lg', 'Large'], ['xl', 'Extra large']] },
                { key: 'showText', label: 'Show name & subtitle', type: 'bool' },
                { key: 'status', label: 'Status dot', type: 'select', options: [['none', 'None'], ['online', 'Online'], ['away', 'Away'], ['busy', 'Busy'], ['offline', 'Offline']] }
            ]},
            { title: 'Colours', fields: [
                { key: 'bgColor', label: 'Background', type: 'color', hint: 'Circle background for initials / icon.' },
                { key: 'fgColor', label: 'Initials / icon', type: 'color' },
                { key: 'ringColor', label: 'Ring', type: 'color' }
            ]},
            styleGroup() ];
        },
        render: function (n) {
            var p = n.props || {};
            function initials(nm) { var w = String(nm || '').trim().split(/\s+/).filter(Boolean); if (!w.length) return '?'; return (w[0].charAt(0) + (w.length > 1 ? w[w.length - 1].charAt(0) : '')).toUpperCase(); }
            var name, subtitle, media;
            if (p.source === 'dynamic') {
                name = (p.nameColumn ? p.nameColumn + ' value' : 'Name');
                subtitle = p.subtitleColumn ? p.subtitleColumn + ' value' : '';
                media = p.imageColumn ? '<img src="https://i.pravatar.cc/120?img=12" alt="">' : initials(name);
            } else {
                name = txt(n, 'name', 'Jane Doe'); subtitle = txt(n, 'subtitle', '');
                var at = p.avatarType || 'initials';
                media = at === 'image' ? (p.imageUrl ? '<img src="' + esc(p.imageUrl) + '" alt="' + esc(name) + '">' : initials(name))
                    : at === 'icon' ? icon(p.ico || 'bi-person') : initials(name);
            }
            var cls = 'ppd-avatar' + (p.shape === 'rounded' ? ' av-rounded' : p.shape === 'square' ? ' av-square' : '')
                + (['sm', 'lg', 'xl'].indexOf(p.size) > -1 ? ' av-' + p.size : '');
            var sv = (p.bgColor ? '--av-bg:' + p.bgColor + ';' : '') + (p.fgColor ? '--av-color:' + p.fgColor + ';' : '') + (p.ringColor ? '--av-ring:' + p.ringColor + ';' : '');
            var dot = (['online', 'away', 'busy', 'offline'].indexOf(p.status) > -1) ? '<span class="ppd-avatar-dot st-' + p.status + '"></span>' : '';
            var text = (p.showText !== false) ? '<div class="ppd-avatar-text"><strong>' + esc(name) + '</strong>' + (subtitle ? '<small>' + esc(subtitle) + '</small>' : '') + '</div>' : '';
            return '<div class="' + cls + '"' + (sv ? ' style="' + esc(sv) + '"' : '') + '><span class="ppd-avatar-media"><span class="ppd-avatar-img">' + media + '</span>' + dot + '</span>' + text + '</div>';
        } });
    def('signature', { label: 'Signature', icon: 'bi-pen', category: 'Feedback',
        defaults: function () { return { mode: 'draw', label: 'Signature', showLabel: true, fieldName: 'signature', penColor: '#111827', penWidth: 2.5, height: '140px', borderStyle: 'dashed', showBaseline: true, showUndo: true, showRedo: true, showClear: true, showSave: false }; },
        schema: function () {
            var draw = function (p) { return (p.mode || 'draw') !== 'type'; };
            var type = function (p) { return p.mode === 'type'; };
            return [ generalGroup([
                { key: 'mode', label: 'Mode', type: 'radiogroup', options: [['draw', 'Draw'], ['type', 'Type']], rerender: true, hint: 'Draw = sign with mouse/finger on a canvas. Type = type your name in a signature font.' },
                { key: 'label', label: 'Label', type: 'text' },
                { key: 'showLabel', label: 'Show label', type: 'bool' },
                { key: 'required', label: 'Required', type: 'bool' },
                { key: 'placeholder', label: 'Placeholder', type: 'text', when: type, placeholder: 'Type your name' }
            ]),
            { title: 'Pen', fields: [
                { key: 'penColor', label: 'Pen colour', type: 'color', when: draw },
                { key: 'penWidth', label: 'Pen thickness', type: 'number', when: draw, placeholder: '2.5' },
                { key: 'showBaseline', label: 'Signature line', type: 'bool', when: draw }
            ]},
            { title: 'Store in database', fields: [
                { key: 'saveTable', label: 'Table', type: 'dbtable', hint: 'The table this signature is stored in.' },
                { key: 'saveKey', label: 'PK field', type: 'dbcolumn', tableKey: 'saveTable', when: draw, hint: 'The primary-key column that identifies the record (usually “id”). Used by the Save button; its value is read at runtime from the form field of the same name, or ?<key>= in the page URL.' },
                { key: 'fieldName', label: 'Signature column', type: 'dbcolumn', tableKey: 'saveTable', filter: 'text', hint: 'The column that stores the signature — only text/blob columns are listed (TEXT/MEDIUMTEXT/LONGTEXT/CHAR/VARCHAR…). Used by the form on submit AND by the Save button.' }
            ]},
            { title: 'Buttons', fields: [
                { key: 'showUndo', label: 'Undo button', type: 'bool', when: draw },
                { key: 'showRedo', label: 'Redo button', type: 'bool', when: draw },
                { key: 'showClear', label: 'Clear button', type: 'bool', when: draw },
                { key: 'showSave', label: 'Save button', type: 'bool', when: draw, hint: 'Persists the signature to the record above instantly (needs Table + PK field + Signature column). Without them it just confirms locally.' }
            ]},
            { title: 'Typed style', fields: [
                { key: 'typedFont', label: 'Font', type: 'font', when: type },
                { key: 'penColor', label: 'Text colour', type: 'color', when: type }
            ]},
            { title: 'Pad', fields: [
                { key: 'height', label: 'Height', type: 'text', placeholder: 'e.g. 140px' },
                { key: 'bgColor', label: 'Background', type: 'color' },
                { key: 'borderStyle', label: 'Border', type: 'select', options: [['dashed', 'Dashed'], ['solid', 'Solid'], ['none', 'None']] },
                { key: 'borderColor', label: 'Border colour', type: 'color' },
                { key: 'radius', label: 'Corner radius', type: 'text', placeholder: 'e.g. 8px' }
            ]},
            styleGroup() ];
        },
        render: function (n) {
            var p = n.props || {};
            var mode = p.mode === 'type' ? 'type' : 'draw';
            var vars = [];
            if (p.height) vars.push('--sig-h:' + p.height);
            if (p.bgColor) vars.push('--sig-bg:' + p.bgColor);
            if (p.borderColor) vars.push('--sig-border:' + p.borderColor);
            if (p.radius) vars.push('--sig-radius:' + p.radius);
            if (p.penColor) vars.push('--sig-pen:' + p.penColor);
            if (p.typedFont) vars.push('--sig-font:' + p.typedFont);
            var cls = 'ppd-sig' + (p.borderStyle === 'solid' ? ' sig-solid' : p.borderStyle === 'none' ? ' sig-none' : '');
            var sv = vars.length ? ' style="' + esc(vars.join(';')) + '"' : '';
            var lbl = (p.showLabel !== false) ? '<label>' + txt(n, 'label', 'Signature') + (p.required ? ' <span class="ppd-sig-req">*</span>' : '') + '</label>' : '';
            if (mode === 'type') {
                return '<div class="ppd-c-field">' + lbl + '<div class="' + cls + '"' + sv + '><div class="ppd-sig-typebox">' + esc(p.placeholder || 'Type your name') + '</div></div></div>';
            }
            var pad = '<div class="' + cls + '"' + sv + '><div class="ppd-sig-preview">' + icon('bi-pen') + ' Sign here</div>'
                + (p.showBaseline !== false ? '<div class="ppd-sig-baseline"></div>' : '') + '</div>';
            var btn = function (ic, lab, on, prim) { return '<span class="ppd-sig-btn' + (prim ? ' is-primary' : '') + (on ? '' : ' is-off') + '">' + icon(ic) + (lab ? ' ' + lab : '') + '</span>'; };
            var tb = '';
            if (p.showUndo !== false) tb += btn('bi-arrow-counterclockwise', '', true);
            if (p.showRedo !== false) tb += btn('bi-arrow-clockwise', '', true);
            if (p.showClear !== false) tb += btn('bi-x-lg', '', true);
            if (p.showSave) tb += btn('bi-check-lg', 'Save', true, true);
            var toolbar = tb ? '<div class="ppd-sig-toolbar">' + tb + '</div>' : '';
            return '<div class="ppd-c-field">' + lbl + pad + toolbar + '</div>';
        } });
    def('qr-code', { label: 'QR Code', icon: 'bi-qr-code', category: 'Feedback',
        defaults: function () { return { value: 'https://example.com', contentType: 'text', size: 160, fgColor: '#000000', bgColor: '#ffffff', margin: 1, ecc: 'M', format: 'png' }; },
        schema: function () { return [ generalGroup([
            { key: 'value', label: 'Encodes', type: 'text', placeholder: 'https://…  or any text', hint: 'The data the QR code contains.' },
            { key: 'contentType', label: 'Content', type: 'select', options: [['text', 'Plain text'], ['url', 'Website URL'], ['email', 'Email'], ['phone', 'Phone'], ['sms', 'SMS']], hint: 'Formats the value so a phone opens the right app (tel:, mailto:…).' }
        ]),
        { title: 'Code', fields: [
            { key: 'size', label: 'Size (px)', type: 'number', placeholder: '160' },
            { key: 'fgColor', label: 'Foreground', type: 'color' },
            { key: 'bgColor', label: 'Background', type: 'color' },
            { key: 'margin', label: 'Quiet zone', type: 'number', placeholder: '1', hint: 'Blank border around the code (modules).' },
            { key: 'ecc', label: 'Error correction', type: 'select', options: [['L', 'Low (7%)'], ['M', 'Medium (15%)'], ['Q', 'Quartile (25%)'], ['H', 'High (30%)']], hint: 'Higher survives more damage/logos but is denser.' },
            { key: 'format', label: 'Format', type: 'select', options: [['png', 'PNG'], ['svg', 'SVG (crisp)']] }
        ]},
        { title: 'Frame', fields: [
            { key: 'framePad', label: 'Padding', type: 'text', placeholder: 'e.g. 10px' },
            { key: 'frameBg', label: 'Frame background', type: 'color' },
            { key: 'frameRadius', label: 'Corner radius', type: 'text', placeholder: 'e.g. 12px' },
            { key: 'frameBorder', label: 'Border', type: 'bool' },
            { key: 'caption', label: 'Caption', type: 'text', placeholder: 'Optional text under the code' }
        ]},
        styleGroup() ]; },
        render: function (n) {
            var p = n.props || {};
            var v = String(p.value == null ? '' : p.value).trim();
            if (!v) return '<div class="ppd-qr">' + icon('bi-qr-code') + '</div>';
            var ct = p.contentType || 'text';
            if (ct === 'url' && !/^[a-z][a-z0-9+.-]*:/i.test(v)) v = 'https://' + v;
            else if (ct === 'email') v = 'mailto:' + v;
            else if (ct === 'phone') v = 'tel:' + v;
            else if (ct === 'sms') v = 'sms:' + v;
            var s = Math.max(60, Math.min(1000, parseInt(p.size, 10) || 160));
            function hx(c, d) { c = String(c || '').replace('#', ''); return /^[0-9a-fA-F]{6}$/.test(c) ? c : (/^[0-9a-fA-F]{3}$/.test(c) ? c.replace(/(.)/g, '$1$1') : d); }
            var fg = hx(p.fgColor, '000000'), bg = hx(p.bgColor, 'ffffff');
            var m = Math.max(0, Math.min(50, parseInt(p.margin, 10) || 0));
            var ecc = ['L', 'M', 'Q', 'H'].indexOf(p.ecc) > -1 ? p.ecc : 'M';
            var fmt = p.format === 'svg' ? 'svg' : 'png';
            var url = 'https://api.qrserver.com/v1/create-qr-code/?size=' + s + 'x' + s + '&data=' + encodeURIComponent(v) + '&color=' + fg + '&bgcolor=' + bg + '&margin=' + m + '&ecc=' + ecc + '&format=' + fmt;
            var fr = (p.framePad ? 'padding:' + p.framePad + ';' : '') + (p.frameBg ? 'background:' + p.frameBg + ';' : '') + (p.frameRadius ? 'border-radius:' + p.frameRadius + ';' : '') + (p.frameBorder ? 'border:1px solid var(--ppd-border);' : '');
            var img = '<img class="ppd-qr-img" src="' + esc(url) + '" width="' + s + '" height="' + s + '" alt="QR code" loading="lazy"' + (fr ? ' style="' + esc(fr) + '"' : '') + '>';
            var cap = (p.caption || '').trim() ? '<div class="ppd-qr-cap">' + esc(p.caption) + '</div>' : '';
            return '<div class="ppd-qr-box">' + img + cap + '</div>';
        } });
    def('wizard-step', { label: 'Wizard Step', icon: 'bi-signpost-2', category: 'Feedback',
        defaults: function () { return { steps: [{ label: 'Details' }, { label: 'Options' }, { label: 'Review' }], active: 1, marker: 'number', doneCheck: true, showDesc: true, connector: true, counter: false, size: 'md', orientation: 'horizontal', labelPos: 'inline' }; },
        schema: function () { return [ generalGroup([
            { key: 'steps', label: 'Steps', type: 'list', itemLabel: 'Step', addLabel: 'Add step',
              default: { label: 'New step', desc: '' },
              item: [
                { key: 'label', label: 'Label', type: 'text' },
                { key: 'desc', label: 'Description', type: 'text', placeholder: 'Optional subtitle' },
                { key: 'icon', label: 'Icon', type: 'icon', hint: 'Shown when Marker = Icon.' }
              ] },
            { key: 'active', label: 'Current step (0-based)', type: 'number', hint: 'Which step is active. Earlier steps show as completed.' }
        ]),
        { title: 'Layout', fields: [
            { key: 'orientation', label: 'Orientation', type: 'select', options: [['horizontal', 'Horizontal'], ['vertical', 'Vertical']] },
            { key: 'marker', label: 'Marker', type: 'select', options: [['number', 'Number'], ['icon', 'Icon'], ['dot', 'Dot']], hint: 'Number = 1·2·3 · Icon = each step’s icon · Dot = minimal.' },
            { key: 'doneCheck', label: 'Check completed steps', type: 'bool', hint: 'Show ✓ on steps before the current one.' },
            { key: 'showDesc', label: 'Show descriptions', type: 'bool' },
            { key: 'labelPos', label: 'Labels', type: 'select', options: [['inline', 'Beside marker'], ['stacked', 'Below marker']], when: function (p) { return (p.orientation || 'horizontal') !== 'vertical'; }, hint: 'Stacked centres the marker above the label.' },
            { key: 'connector', label: 'Show connectors', type: 'bool', hint: 'The line between steps.' },
            { key: 'counter', label: 'Show “Step X of Y”', type: 'bool' },
            { key: 'size', label: 'Size', type: 'select', options: [['sm', 'Small'], ['md', 'Medium'], ['lg', 'Large']] }
        ]},
        { title: 'Label', fields: [
            { key: 'labelFont', label: 'Font', type: 'font' },
            { key: 'labelSize', label: 'Font size', type: 'text', placeholder: 'e.g. 13px' },
            { key: 'labelWeight', label: 'Weight', type: 'select', options: ['', 'normal', '500', '600', '700', '800'] },
            { key: 'labelColor', label: 'Colour', type: 'color' }
        ]},
        { title: 'Description', fields: [
            { key: 'descFont', label: 'Font', type: 'font' },
            { key: 'descSize', label: 'Font size', type: 'text', placeholder: 'e.g. 11px' },
            { key: 'descColor', label: 'Colour', type: 'color' }
        ]},
        { title: 'Step background', fields: [
            { key: 'stepBg', label: 'Background', type: 'color', hint: 'Background of each step pill.' },
            { key: 'gradient', label: 'Use gradient', type: 'bool', rerender: true, hint: 'Blend two colours across the pill.' },
            { key: 'stepBg2', label: 'Gradient to', type: 'color', when: function (p) { return !!p.gradient; } },
            { key: 'gradAngle', label: 'Angle', type: 'text', placeholder: '135deg', when: function (p) { return !!p.gradient; }, hint: 'e.g. 135deg, to right, 90deg.' }
        ]},
        { title: 'Marker', fields: [
            { key: 'accent', label: 'Accent colour', type: 'color', hint: 'Active + completed colour.' },
            { key: 'markerBg', label: 'Marker background', type: 'color', hint: 'Upcoming-step circle background.' },
            { key: 'markerColor', label: 'Marker text / icon', type: 'color' }
        ]},
        styleGroup() ]; },
        render: function (n) {
            var p = n.props || {};
            var steps = (Array.isArray(p.steps) ? p.steps : []).map(function (s) { return (typeof s === 'string') ? { label: s } : (s || {}); });
            if (!steps.length) steps = [{ label: 'Step 1' }, { label: 'Step 2' }, { label: 'Step 3' }];
            var active = parseInt(p.active, 10); if (isNaN(active)) active = 0;
            var marker = p.marker || 'number', vert = p.orientation === 'vertical';
            var showDesc = p.showDesc !== false, doneCheck = p.doneCheck !== false, connector = p.connector !== false;
            var cls = 'ppd-steps'
                + (vert ? ' wz-vert' : (p.labelPos === 'stacked' ? ' wz-stack' : ''))
                + (p.size === 'sm' ? ' wz-sm' : p.size === 'lg' ? ' wz-lg' : '')
                + (marker === 'dot' ? ' wz-mk-dot' : '')
                + (connector ? '' : ' wz-noline');
            var g = [];
            if (p.accent) g.push('--wz-accent:' + p.accent);
            if (p.gradient && (p.stepBg || p.stepBg2)) g.push('--wz-step-bg:linear-gradient(' + (p.gradAngle || '135deg') + ',' + (p.stepBg || '#6366f1') + ',' + (p.stepBg2 || p.stepBg || '#8b5cf6') + ')');
            else if (p.stepBg) g.push('--wz-step-bg:' + p.stepBg);
            if (p.markerBg) g.push('--wz-marker-bg:' + p.markerBg);
            if (p.markerColor) g.push('--wz-marker-color:' + p.markerColor);
            if (p.labelFont) g.push('--wz-label-font:' + p.labelFont);
            if (p.labelSize) g.push('--wz-label-size:' + p.labelSize);
            if (p.labelWeight) g.push('--wz-label-weight:' + p.labelWeight);
            if (p.labelColor) g.push('--wz-label-color:' + p.labelColor);
            if (p.descFont) g.push('--wz-desc-font:' + p.descFont);
            if (p.descSize) g.push('--wz-desc-size:' + p.descSize);
            if (p.descColor) g.push('--wz-desc-color:' + p.descColor);
            var sv = g.length ? ' style="' + esc(g.join(';')) + '"' : '';
            function marky(i, s) {
                if (marker === 'dot') return '';
                if (i < active && doneCheck) return icon('bi-check-lg');
                if (marker === 'icon' && s.icon) return icon(s.icon);
                return (i + 1);
            }
            var body = steps.map(function (s, i) {
                s = s || {};
                var stCls = 'ppd-step' + (i === active ? ' is-active' : i < active ? ' is-done' : '');
                var lbl = '<span class="ppd-step-lbl"><span>' + esc(s.label || '') + '</span>' + (showDesc && s.desc ? '<small>' + esc(s.desc) + '</small>' : '') + '</span>';
                return '<div class="' + stCls + '"><span class="ppd-step-n">' + marky(i, s) + '</span>' + lbl + '</div>' + (i < steps.length - 1 ? '<span class="ppd-step-bar"></span>' : '');
            }).join('');
            var stepsEl = '<div class="' + cls + '"' + sv + '>' + body + '</div>';
            if (p.counter) {
                var cur = Math.max(0, Math.min(active, steps.length - 1));
                return '<div class="ppd-steps-wrap"><div class="ppd-steps-counter">Step ' + (cur + 1) + ' of ' + steps.length + '</div>' + stepsEl + '</div>';
            }
            return stepsEl;
        } });

    // Dashboard widgets
    def('kpi-trend-card', { label: 'KPI Trend Card', icon: 'bi-graph-up-arrow', category: 'Dashboard Widgets',
        defaults: function () { return { label: 'Revenue', source: 'manual', value: '$48.2k', delta: '+12.4%', trend: 'up', deltaMode: 'compare', decimals: 0 }; },
        schema: function () { return [ generalGroup([
            { key: 'label', label: 'Label', type: 'text', hint: 'The metric name shown above the value (e.g. “Revenue”).' },
            { key: 'source', label: 'Value from', type: 'radiogroup', options: ['manual', 'dynamic'], rerender: true,
              hint: 'Manual = you type the value & delta. Dynamic = SQL reads them live from the database.' },
            // ── Manual ──
            { key: 'value', label: 'Value', type: 'text', when: { key: 'source', in: ['manual', ''] },
              hint: 'The big number/text shown (e.g. “$48.2k”, “1,204”).' },
            { key: 'delta', label: 'Delta', type: 'text', when: { key: 'source', in: ['manual', ''] },
              hint: 'Small change text under the value (e.g. “+12.4%”). Blank = hide it.' },
            { key: 'trend', label: 'Trend', type: 'select', options: ['up', 'down', 'flat'], when: { key: 'source', in: ['manual', ''] },
              hint: 'Arrow + colour: up = green ▲, down = red ▼, flat = grey.' },
            // ── Dynamic: value ──
            { key: 'query', label: 'Value SQL (returns a number)', type: 'text', full: true, when: { key: 'source', in: ['dynamic'] },
              placeholder: 'SELECT SUM(amount) FROM orders WHERE created_at >= :startof-month',
              hint: 'A SELECT returning ONE number = the current value. Runs once on page load; supports SQL variables.',
              note: 'Supports SQL variables — <code>:userid</code>, <code>:startof-month</code>, <code>:today</code>, <code>:field.name</code>.' },
            { key: 'prefix', label: 'Prefix', type: 'text', when: { key: 'source', in: ['dynamic'] }, hint: 'Text before the number, e.g. “$”.' },
            { key: 'suffix', label: 'Suffix', type: 'text', when: { key: 'source', in: ['dynamic'] }, hint: 'Text after the number, e.g. “k”, “%”, “ orders”.' },
            { key: 'decimals', label: 'Decimals', type: 'number', when: { key: 'source', in: ['dynamic'] }, hint: 'Digits after the decimal point (0–4).' },
            { key: 'abbreviate', label: 'Abbreviate big numbers (1.2k, 3.4M)', type: 'bool', when: { key: 'source', in: ['dynamic'] }, hint: '48,200 → 48.2k · 1,200,000 → 1.2M.' },
            { key: 'thousands', label: 'Thousands separator', type: 'bool', when: { key: 'source', in: ['dynamic'] }, hint: 'Group digits: 1204 → 1,204. (Ignored when abbreviating.)' },
            // ── Dynamic: delta / trend ──
            { key: 'deltaMode', label: 'Delta', type: 'select', options: ['compare', 'query', 'none'], when: { key: 'source', in: ['dynamic'] }, rerender: true,
              hint: 'compare = auto % change vs a “previous” value · query = a SQL that returns the delta directly · none = hide it.' },
            { key: 'compareQuery', label: 'Previous-value SQL', type: 'text', full: true,
              when: function (p) { return p.source === 'dynamic' && (p.deltaMode || 'compare') === 'compare'; },
              placeholder: 'SELECT SUM(amount) FROM orders WHERE created_at >= :startof-lastmonth AND created_at < :startof-month',
              hint: 'The prior-period value. Delta % = (current − previous) ÷ previous × 100; the trend arrow is derived automatically.',
              note: 'Supports SQL variables — pair <code>:startof-lastmonth</code> with <code>:startof-month</code> for month-over-month.' },
            { key: 'deltaQuery', label: 'Delta SQL (returns a number)', type: 'text', full: true,
              when: function (p) { return p.source === 'dynamic' && p.deltaMode === 'query'; },
              placeholder: 'SELECT COUNT(*) FROM signups WHERE created_at >= :today',
              hint: 'A SELECT returning the delta number directly (its sign sets the trend: + → up, − → down).',
              note: 'Supports SQL variables.' }
        ]), { title: 'Card', fields: [
            { key: 'icon', label: 'Corner icon', type: 'icon', hint: 'Optional icon shown top-right of the card.' }
        ] }, styleGroup() ]; },
        render: function (n) {
            var p = n.props || {};
            var dyn = p.source === 'dynamic';
            // JS twin of the generated-app pp_kpi_fmt() — lets the canvas preview a
            // SAMPLE value so Decimals / Abbreviate / Thousands update live.
            var fmt = function (num) {
                var dec = Math.max(0, Math.min(4, parseInt(p.decimals, 10) || 0)), a = Math.abs(num), s;
                if (p.abbreviate && a >= 1e12) s = (num / 1e12).toFixed(dec) + 'T';
                else if (p.abbreviate && a >= 1e9) s = (num / 1e9).toFixed(dec) + 'B';
                else if (p.abbreviate && a >= 1e6) s = (num / 1e6).toFixed(dec) + 'M';
                else if (p.abbreviate && a >= 1e3) s = (num / 1e3).toFixed(dec) + 'k';
                else { s = num.toFixed(dec); if (p.thousands) s = s.replace(/\B(?=(\d{3})+(?!\d))/g, ','); }
                return s;
            };
            var trend, valueHtml, deltaHtml, showDelta;
            if (dyn) {
                valueHtml = esc(p.prefix || '') + fmt(48200) + esc(p.suffix || '');   // sample
                var dm = p.deltaMode || 'compare';
                showDelta = dm !== 'none';
                trend = 'up';
                deltaHtml = dm === 'query' ? ('+' + fmt(1240)) : '+12.4%';
            } else {
                valueHtml = txt(n, 'value', '0');
                trend = p.trend || 'up';
                deltaHtml = txt(n, 'delta', '');
                showDelta = (p.delta != null && String(p.delta) !== '');
            }
            // Trend (and its colour) is data-driven: greater → up/green, less → down/red.
            var colorCls = (trend === 'flat') ? 'tr-flat' : 'tr-' + esc(trend);
            var arrow = icon(trend === 'down' ? 'bi-arrow-down-right' : trend === 'flat' ? 'bi-arrow-right' : 'bi-arrow-up-right');
            var head = '<div class="ppd-widget-h"><small' + elAttrs(n, 'label') + '>' + txt(n, 'label', 'KPI') + '</small>'
                + (p.icon ? '<span class="ppd-kpi-ic">' + icon(p.icon) + '</span>' : '')
                + (dyn ? '<i class="bi bi-database ppd-dynbadge" title="Dynamic — from SQL"></i>' : '') + '</div>';
            var deltaEl = showDelta ? ('<div' + elAttrs(n, 'delta', 'ppd-kpi-delta ' + colorCls) + '>' + arrow + deltaHtml + '</div>') : '';
            return '<div class="ppd-widget">' + head + '<div' + elAttrs(n, 'value', 'ppd-kpi-value') + '>' + valueHtml + '</div>' + deltaEl + '</div>';
        } });
    def('recent-activity', { label: 'Recent Activity', icon: 'bi-clock-history', category: 'Dashboard Widgets',
        defaults: function () { return { heading: 'Recent activity', count: 4 }; },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text' },
            { key: 'table', label: 'Table', type: 'dbtable' },
            { key: 'titleColumn', label: 'Title column', type: 'dbcolumn', tableKey: 'table' },
            { key: 'dateColumn', label: 'Date column (recent first)', type: 'dbcolumn', tableKey: 'table' },
            { key: 'count', label: 'Rows', type: 'number' }
        ]), styleGroup() ]; },
        render: function (n) { var c = Math.max(1, Math.min(12, parseInt(n.props.count, 10) || 4)); var bound = (n.props.table || '').trim(); var rows = ''; for (var i = 0; i < c; i++) rows += '<div class="ppd-feed-row">' + icon('bi-clock-history') + '<div><span class="k">' + (bound ? esc(title(bound)) + ' record' : 'Record updated') + '</span><small>' + (bound ? 'live' : 'a few minutes ago') + '</small></div></div>'; return '<div class="ppd-widget"><div class="ppd-widget-h"><strong' + elAttrs(n, 'heading') + '>' + txt(n, 'heading', 'Recent activity') + '</strong></div>' + rows + '</div>'; } });
    def('notification-list', { label: 'Notifications', icon: 'bi-bell', category: 'Dashboard Widgets',
        defaults: function () { return { heading: 'Notifications', count: 3 }; },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text' },
            { key: 'table', label: 'Table', type: 'dbtable' },
            { key: 'titleColumn', label: 'Title column', type: 'dbcolumn', tableKey: 'table' },
            { key: 'dateColumn', label: 'Date column (recent first)', type: 'dbcolumn', tableKey: 'table' },
            { key: 'count', label: 'Rows', type: 'number' }
        ]), styleGroup() ]; },
        render: function (n) { var c = Math.max(1, Math.min(12, parseInt(n.props.count, 10) || 3)); var bound = (n.props.table || '').trim(); var rows = ''; for (var i = 0; i < c; i++) rows += '<div class="ppd-feed-row">' + icon('bi-bell') + '<div><span class="k">' + (bound ? esc(title(bound)) + ' item' : 'New notification') + '</span><small>' + (bound ? 'live' : 'Unread') + '</small></div></div>'; return '<div class="ppd-widget"><div class="ppd-widget-h"><strong' + elAttrs(n, 'heading') + '>' + txt(n, 'heading', 'Notifications') + '</strong></div>' + rows + '</div>'; } });
    def('task-list', { label: 'Task List', icon: 'bi-check2-square', category: 'Dashboard Widgets',
        defaults: function () { return { heading: 'Tasks', source: 'manual', items: ['Review submissions', '[x] Approve requests', 'Send report'], showChecks: true, strikeDone: true, orderDir: 'asc', limit: 6 }; },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text', hint: 'Title shown above the list.' },
            { key: 'countBadge', label: 'Show count in heading', type: 'bool', hint: 'Append the number of tasks, e.g. “Tasks (3)”.' },
            { key: 'source', label: 'Tasks from', type: 'radiogroup', options: ['manual', 'dynamic'], rerender: true,
              hint: 'Manual = you type the list. Dynamic = rows come live from a database table.' },
            // ── Manual ──
            { key: 'items', label: 'Tasks (comma-separated)', type: 'csv', when: { key: 'source', in: ['manual', ''] },
              placeholder: 'Review submissions, [x] Approve requests, Send report',
              hint: 'Prefix an item with [x] to show it done (checked + struck through), e.g. “[x] Ship release”.' },
            // ── Dynamic ──
            { key: 'table', label: 'Table', type: 'dbtable', when: { key: 'source', in: ['dynamic'] }, hint: 'The table each task row comes from.' },
            { key: 'titleColumn', label: 'Task text column', type: 'dbcolumn', tableKey: 'table', when: { key: 'source', in: ['dynamic'] }, hint: 'Column holding the task label.' },
            { key: 'doneColumn', label: 'Done column (optional)', type: 'dbcolumn', tableKey: 'table', when: { key: 'source', in: ['dynamic'] }, hint: 'A boolean/flag column — non-empty or 1 = the task shows as done (checked + struck).' },
            { key: 'where', label: 'Filter (WHERE, optional)', type: 'text', full: true, when: { key: 'source', in: ['dynamic'] },
              placeholder: 'done = 0 AND assigned_to = :userid',
              hint: 'SQL condition, without the word WHERE. Supports SQL variables.',
              note: 'e.g. <code>done = 0 AND assigned_to = :userid</code> — supports <code>:userid</code>, <code>:today</code>, <code>:field.name</code>.' },
            { key: 'orderBy', label: 'Sort by column', type: 'dbcolumn', tableKey: 'table', when: { key: 'source', in: ['dynamic'] }, hint: 'Column to order rows by (blank = natural table order).' },
            { key: 'orderDir', label: 'Sort direction', type: 'select', options: ['asc', 'desc'], when: { key: 'source', in: ['dynamic'] }, hint: 'Ascending or descending.' },
            { key: 'limit', label: 'Max rows', type: 'number', when: { key: 'source', in: ['dynamic'] }, hint: 'How many tasks to show (1–50).' },
            { key: 'emptyText', label: 'Empty message', type: 'text', when: { key: 'source', in: ['dynamic'] }, hint: 'Shown when the query returns no rows (e.g. “All caught up 🎉”).' }
        ]), { title: 'Options', fields: [
            { key: 'showChecks', label: 'Show checkboxes', type: 'bool', def: true, hint: 'Show the checkbox in front of each task.' },
            { key: 'strikeDone', label: 'Strike-through done', type: 'bool', def: true, hint: 'Cross out and dim completed tasks.' },
            { key: 'scroll', label: 'Scroll if long', type: 'bool', hint: 'Cap the height and scroll instead of growing tall.' }
        ] }, styleGroup() ]; },
        render: function (n) {
            var p = n.props || {};
            var dyn = p.source === 'dynamic';
            var showChecks = p.showChecks !== false, strikeDone = p.strikeDone !== false;
            var parse = function (s) { s = String(s); var m = s.match(/^\s*\[([ xX])\]\s*/); return m ? { text: s.slice(m[0].length), done: m[1].toLowerCase() === 'x' } : { text: s.trim(), done: false }; };
            var tasks;
            if (dyn) {
                tasks = [{ text: (p.titleColumn || 'task') + ' from ' + (p.table ? title(p.table) : 'table'), done: false }, { text: 'Second row', done: false }, { text: 'Completed row', done: true }];
            } else {
                tasks = (p.items || ['Task one', 'Task two']).map(parse);
            }
            var badge = p.countBadge ? (' (' + tasks.length + ')') : '';
            var rows = tasks.map(function (t) {
                var struck = t.done && strikeDone;
                return '<label class="ppd-task-row' + (struck ? ' is-done' : '') + '">'
                    + (showChecks ? '<input type="checkbox"' + (t.done ? ' checked' : '') + ' disabled>' : '')
                    + '<span>' + esc(t.text) + '</span></label>';
            }).join('');
            var head = '<div class="ppd-widget-h"><strong' + elAttrs(n, 'heading') + '>' + txt(n, 'heading', 'Tasks') + esc(badge) + '</strong>'
                + (dyn ? '<i class="bi bi-database ppd-dynbadge" title="Dynamic — from SQL"></i>' : '') + '</div>';
            return '<div class="ppd-widget">' + head + '<div class="ppd-task-list' + (p.scroll ? ' is-scroll' : '') + '">' + rows + '</div></div>';
        } });
    // Welcome hero for the Menu / landing page. Mirrors ComponentRenderer's
    // 'greeting' case exactly — the build computes the greeting in PHP, this
    // computes the same thing in JS, so canvas and app agree.
    // The hero dial IS the standalone Clock component. These map the greeting's
    // `clk*` props → clock props (with hero defaults) and expose a curated subset
    // of the clock's controls in the hero inspector. Mirrored in PHP by
    // ComponentRenderer::heroClockProps() so canvas and build agree.
    function heroClockProps(p) {
        p = p || {};
        var b = function (k, d) { var v = p[k]; return v == null ? d : (v === true || v === 'yes' || v === 'true' || v === 1 || v === '1'); };
        var s = function (k) { return p[k] == null ? '' : String(p[k]); };
        var d = function (k, dv) { return (p[k] != null && p[k] !== '') ? p[k] : dv; };
        return {
            size: parseInt(p.clkSize, 10) || 150, align: 'center',
            transparent: b('clkTransparent', false),
            showHour: b('clkShowHour', true), showMin: b('clkShowMin', true), showSec: b('clkShowSec', true),
            showDay: b('clkShowDay', true), showDate: b('clkShowDate', true), showEmoji: b('clkShowEmoji', true),
            hourColor: s('clkHourColor'), minColor: s('clkMinColor'), secColor: s('clkSecColor'), tickColor: s('clkTickColor'),
            faceColor: s('clkFaceColor'), faceColor2: s('clkFaceColor2'), faceStyle: d('clkFaceStyle', 'solid'),
            centerType: d('clkCenterType', 'none'), image: s('clkImage'), text: s('clkText'),
            centerBorder: b('clkCenterBorder', true), textColor: s('clkTextColor'),
            centerSize: (p.clkCenterSize != null ? p.clkCenterSize : 48),
            emMorning: (p.clkEmMorning != null ? p.clkEmMorning : '🌅'),
            emAfternoon: (p.clkEmAfternoon != null ? p.clkEmAfternoon : '☀️'),
            emEvening: (p.clkEmEvening != null ? p.clkEmEvening : '🌇'),
            emNight: (p.clkEmNight != null ? p.clkEmNight : '🌙')
        };
    }
    // Inspector groups shown in the hero only when Hero visual = clock. `clk*`
    // keys are flat greeting props; show* bools default ON via `def:true`.
    // Overlay / offset / alignment are omitted — the hero fixes the clock's place.
    function heroClockGroups() {
        return [
            { title: 'Clock · dial', fields: [
                { key: 'clkSize', label: 'Size', type: 'range', min: 90, max: 320, step: 5, suffix: 'px', def: 150 },
                { key: 'clkTransparent', label: 'Transparent (no frosted card)', type: 'bool' },
                { key: 'clkFaceColor', label: 'Face colour', type: 'color' },
                { key: 'clkFaceStyle', label: 'Face fill', type: 'select', options: ['solid', 'linear', 'radial'] },
                { key: 'clkFaceColor2', label: 'Face · 2nd colour', type: 'color', when: 'clkFaceColor' },
                { key: 'clkTickColor', label: 'Hour bars', type: 'color' }
            ]},
            { title: 'Clock · hands', fields: [
                { key: 'clkShowHour', label: 'Hour hand', type: 'bool', def: true },
                { key: 'clkShowMin', label: 'Minute hand', type: 'bool', def: true },
                { key: 'clkShowSec', label: 'Second hand', type: 'bool', def: true },
                { key: 'clkHourColor', label: 'Hour colour', type: 'color' },
                { key: 'clkMinColor', label: 'Minute colour', type: 'color' },
                { key: 'clkSecColor', label: 'Second colour', type: 'color' }
            ]},
            { title: 'Clock · centre & chips', fields: [
                { key: 'clkCenterType', label: 'Centre content', type: 'select', options: ['none', 'image', 'text'], rerender: true },
                { key: 'clkImage', label: 'Centre image URL', type: 'text', placeholder: 'https://…', when: { key: 'clkCenterType', eq: 'image' } },
                { key: 'clkCenterBorder', label: 'Image border', type: 'bool', def: true, when: { key: 'clkCenterType', eq: 'image' } },
                { key: 'clkText', label: 'Centre text', type: 'text', placeholder: 'e.g. PP', when: { key: 'clkCenterType', eq: 'text' } },
                { key: 'clkTextColor', label: 'Text colour', type: 'color', when: { key: 'clkCenterType', eq: 'text' } },
                { key: 'clkCenterSize', label: 'Centre size', type: 'range', min: 0, max: 95, step: 2, suffix: '%', def: 48 },
                { key: 'clkShowDay', label: 'Show day chip', type: 'bool', def: true },
                { key: 'clkShowDate', label: 'Show date chip', type: 'bool', def: true },
                { key: 'clkShowEmoji', label: 'Show time-of-day symbol', type: 'bool', def: true }
            ]},
            { title: 'Clock · time-of-day symbol', fields: [
                { key: 'clkEmMorning', label: 'Morning · 5–12', type: 'text', placeholder: '🌅 · bi-sunrise · https://…' },
                { key: 'clkEmAfternoon', label: 'Afternoon · 12–17', type: 'text', placeholder: '☀️ · bi-sun · https://…' },
                { key: 'clkEmEvening', label: 'Evening · 17–20', type: 'text', placeholder: '🌇 · bi-sunset · https://…' },
                { key: 'clkEmNight', label: 'Night · 20–5', type: 'text', placeholder: '🌙 · bi-moon · https://…' }
            ]}
        ];
    }
    // Welcome hero for the Menu / landing page. Mirrors ComponentRenderer's
    // 'greeting' case exactly — both compute the greeting from LOCAL time (the
    // server runs in UTC, so a server-side greeting is wrong for most users).
    def('greeting', { label: 'Welcome Hero', icon: 'bi-emoji-sunglasses', category: 'Dashboard Widgets',
        defaults: function () { return { title: '', subtitle: 'Here is what is happening today.', showName: true, showDate: true, art: 'clock' }; },
        schema: function (n) {
            var groups = [ generalGroup([
                { key: 'title', label: 'Title override', type: 'text' },
                { key: 'subtitle', label: 'Subtitle', type: 'text' },
                { key: 'showName', label: 'Greet by name', type: 'bool', def: true },
                { key: 'showDate', label: 'Show date', type: 'bool', def: true },
                { key: 'art', label: 'Hero visual', type: 'select', options: ['clock', 'illustration', 'none'], rerender: true }
            ]) ];
            // Selecting the clock reveals its customization controls right here.
            if ((((n && n.props) || {}).art || 'clock') === 'clock') groups = groups.concat(heroClockGroups());
            groups.push(styleGroup());
            return groups;
        },
        render: function (n) {
            var p = n.props || {};
            var h = new Date().getHours();
            var g = h < 5 ? 'Good night' : (h < 12 ? 'Good morning' : (h < 17 ? 'Good afternoon' : (h < 21 ? 'Good evening' : 'Good night')));
            var sub = (p.subtitle || '').trim();
            var d = new Date().toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
            // The canvas has no signed-in app user, so it shows the shape of the
            // greeting rather than inventing a name.
            var head = (p.title || '').trim()
                ? '<span class="pp-greet">' + esc(p.title.trim()) + '</span>'
                : '<span class="pp-greet">' + esc(g) + '</span>' + (p.showName !== false ? '<span class="pp-greet-name">, there</span>' : '');
            // Right-hand visual, mirroring ComponentRenderer: clock | illustration | none.
            var mode = ['clock', 'illustration', 'none'].indexOf(p.art) !== -1 ? p.art : 'clock';
            // The hero dial IS the standalone Clock component (one clock system);
            // it is tinted + sized for the hero purely by CSS (.pp-hero-clock …).
            var clock = '<div class="pp-hero-clock">' + get('clock').render({ props: heroClockProps(p) }) + '</div>';
            var illo = '<div class="pp-hero-art" aria-hidden="true"><svg viewBox="0 0 220 170" focusable="false">' +
                  '<circle class="pa-orb" cx="120" cy="80" r="58"/>' +
                  '<g class="pa-card pa-c3"><rect x="34" y="86" width="118" height="52" rx="10"/>' +
                    '<rect class="pa-line" x="46" y="100" width="52" height="7" rx="3.5"/>' +
                    '<rect class="pa-line dim" x="46" y="114" width="82" height="6" rx="3"/></g>' +
                  '<g class="pa-card pa-c2"><rect x="54" y="52" width="118" height="52" rx="10"/>' +
                    '<rect class="pa-line" x="66" y="66" width="44" height="7" rx="3.5"/>' +
                    '<rect class="pa-line dim" x="66" y="80" width="76" height="6" rx="3"/></g>' +
                  '<g class="pa-card pa-c1"><rect x="74" y="18" width="118" height="52" rx="10"/>' +
                    '<rect class="pa-line" x="86" y="32" width="58" height="7" rx="3.5"/>' +
                    '<rect class="pa-line dim" x="86" y="46" width="68" height="6" rx="3"/></g>' +
                  '<circle class="pa-dot d1" cx="28" cy="60" r="4"/>' +
                  '<circle class="pa-dot d2" cx="196" cy="120" r="5"/>' +
                  '<circle class="pa-dot d3" cx="180" cy="24" r="3"/>' +
                '</svg></div>';
            var art = mode === 'clock' ? clock : (mode === 'illustration' ? illo : '');
            return '<section class="pp-hero"><div class="pp-hero-in">' +
                '<h1' + elAttrs(n, 'title', 'pp-hero-title') + '>' + head + '</h1>' +
                (sub ? '<p' + elAttrs(n, 'subtitle', 'pp-hero-sub') + '>' + esc(sub) + '</p>' : '') +
                (p.showDate !== false ? '<div class="pp-hero-meta"><span' + elAttrs(n, 'date', 'pp-hero-date') + '><i class="bi bi-calendar3"></i> <span>' + esc(d) + '</span></span></div>' : '') +
                '</div>' + art + '</section>';
        } });
    def('quick-links', { label: 'Quick Links', icon: 'bi-link-45deg', category: 'Dashboard Widgets',
        defaults: function () { return { heading: 'Quick links', items: [{ label: 'Add record', icon: 'bi-plus-lg' }, { label: 'Import', icon: 'bi-upload' }, { label: 'Export', icon: 'bi-download' }, { label: 'Settings', icon: 'bi-gear' }] }; },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text' },
            { key: 'items', label: 'Links', type: 'list', itemLabel: 'Link', addLabel: 'Add link',
              default: { label: 'New link', icon: 'bi-link-45deg', url: '' },
              item: [
                { key: 'label', label: 'Label', type: 'text' },
                { key: 'icon', label: 'Icon', type: 'icon' },
                { key: 'url', label: 'URL', type: 'text', placeholder: 'e.g. /records or https://…' }
              ] }
        ]), styleGroup() ]; },
        render: function (n) { var it = n.props.items || [];
            // Mirror ComponentRenderer: a blank heading means no header bar.
            var qh = Object.prototype.hasOwnProperty.call(n.props || {}, 'heading') ? String(n.props.heading || '').trim() : 'Quick links';
            return '<div class="ppd-widget' + (qh === '' ? ' is-bare' : '') + '">' + (qh ? '<div class="ppd-widget-h"><strong' + elAttrs(n, 'heading') + '>' + esc(qh) + '</strong></div>' : '') + '<div class="ppd-quicklinks">' + it.map(function (l) { return '<span class="ppd-quicklink">' + icon(l.icon || 'bi-link-45deg') + '<span>' + esc(l.label || 'Link') + '</span></span>'; }).join('') + '</div></div>'; } });
    def('mini-table', { label: 'Mini Table', icon: 'bi-table', category: 'Dashboard Widgets',
        defaults: function () { return { heading: 'Top records', rows: 4 }; },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text', hint: 'Title shown above the table (e.g. “Top customers”).' },
            { key: 'table', label: 'Table', type: 'dbtable', hint: 'The table whose rows fill the mini table.' },
            { key: 'columns', label: 'Columns (csv, blank = first 4)', type: 'csv', placeholder: 'name, email, status',
              hint: 'Comma-separated column names to show. Leave blank to auto-use the first 4 columns.' },
            { key: 'where', label: 'Filter (WHERE, optional)', type: 'text', full: true,
              placeholder: "status = 'Active' AND created_at >= :startof-month",
              hint: 'SQL condition, without the word WHERE — narrows which rows show. Supports SQL variables.',
              note: "e.g. <code>status = 'Active' AND owner_id = :userid</code> — supports <code>:userid</code>, <code>:today</code>, <code>:startof-month</code>, <code>:field.name</code>." },
            { key: 'orderColumn', label: 'Order by column', type: 'dbcolumn', tableKey: 'table', hint: 'Column to sort rows by (blank = natural table order).' },
            { key: 'orderDir', label: 'Order', type: 'select', options: ['DESC', 'ASC'], hint: 'DESC = newest / highest first · ASC = oldest / lowest first.' },
            { key: 'rows', label: 'Rows', type: 'number', hint: 'How many rows to show (1–20).' }
        ]), styleGroup() ]; },
        render: function (n, ctx) {
            var kb = window.PpDesignerBootstrap || {};
            var tbl = (n.props.table || '').trim();
            var flds = tbl ? ((kb.fieldsByTable || {})[tbl] || []) : ((ctx && ctx.fields) || []);
            var picked = Array.isArray(n.props.columns) && n.props.columns.length ? n.props.columns : flds.slice(0, 4).map(function (f) { return f.name; });
            var head = picked.map(function (c) { return '<th>' + esc(title(c)) + '</th>'; }).join('');
            var r = Math.max(1, Math.min(12, parseInt(n.props.rows, 10) || 4)); var body = '';
            for (var i = 0; i < r; i++) body += '<tr>' + picked.map(function (c) { return '<td><code>' + esc(c) + '</code></td>'; }).join('') + '</tr>';
            var filtered = (n.props.where || '').trim() ? ' <i class="bi bi-funnel-fill ppd-dynbadge" title="Filtered — WHERE applied"></i>' : '';
            return '<div class="ppd-widget"><div class="ppd-widget-h"><strong' + elAttrs(n, 'heading') + '>' + txt(n, 'heading', 'Records') + '</strong>' + filtered + '</div><table class="ppd-minitable"><thead><tr>' + head + '</tr></thead><tbody>' + body + '</tbody></table></div>'; } });
    def('calendar-widget', { label: 'Calendar', icon: 'bi-calendar3', category: 'Dashboard Widgets',
        defaults: function () { return { heading: 'Calendar' }; },
        schema: function () { return [ generalGroup([
            { key: 'heading', label: 'Heading', type: 'text' },
            { key: 'table', label: 'Table (mark days with records)', type: 'dbtable' },
            { key: 'dateColumn', label: 'Date column', type: 'dbcolumn', tableKey: 'table' }
        ]), styleGroup() ]; },
        render: function (n) {
            // Mirror the build (ComponentRenderer::calendarPhp(..., false)): a real
            // month grid with weekday labels + leading-blank offset, using the shared
            // .ppd-mcal* classes. The previous .ppd-cal markup collided with the
            // calendar-app preview (flex-column), which stacked days into a list.
            var now = new Date();
            var mo = now.getMonth() + 1, yr = now.getFullYear();
            var first = new Date(yr, mo - 1, 1).getDay(), dim = new Date(yr, mo, 0).getDate();
            var td = now.getDate();
            var wd = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
            var wdRow = '<div class="ppd-mcal-wd">' + wd.map(function (w) { return '<span>' + w + '</span>'; }).join('') + '</div>';
            var cells = '';
            for (var b = 0; b < first; b++) cells += '<span class="ppd-mcal-day is-blank"></span>';
            for (var i = 1; i <= dim; i++) cells += '<span class="ppd-mcal-day' + (i === td ? ' is-active' : '') + '">' + i + '</span>';
            return '<div class="ppd-widget"><div class="ppd-widget-h"><strong' + elAttrs(n, 'heading') + '>' + txt(n, 'heading', 'Calendar') + '</strong></div>'
                + '<div class="ppd-mcal-wrap">' + wdRow + '<div class="ppd-mcal">' + cells + '</div></div></div>'; } });

    // Content / media
    def('icon', { label: 'Icon', icon: 'bi-star', category: 'Content',
        defaults: function () { return { ico: 'bi-star-fill', size: 32 }; },
        schema: function () { return [ generalGroup([{ key: 'ico', label: 'Icon', type: 'icon' }, { key: 'size', label: 'Size (px)', type: 'number' }]), styleGroup() ]; },
        render: function (n) { var s = parseInt(n.props.size, 10) || 32; return '<div class="ppd-icon-block" style="font-size:' + s + 'px">' + icon(n.props.ico || 'bi-star-fill') + '</div>'; } });
    def('video', { label: 'Video', icon: 'bi-play-circle', category: 'Content',
        defaults: function () { return { src: '' }; },
        schema: function () { return [ generalGroup([{ key: 'src', label: 'Source URL', type: 'text' }]), styleGroup() ]; },
        render: function () { return '<div class="ppd-media">' + icon('bi-play-circle') + '<span>Video</span></div>'; } });
    def('iframe', { label: 'Iframe', icon: 'bi-window', category: 'Content',
        defaults: function () { return { src: '' }; },
        schema: function () { return [ generalGroup([{ key: 'src', label: 'URL', type: 'text' }]), styleGroup() ]; },
        render: function (n) { return '<div class="ppd-media">' + icon('bi-window') + '<span>' + (n.props.src ? esc(n.props.src) : 'Embedded page') + '</span></div>'; } });
    function linkClass(style) { return style === 'button' ? 'ppd-linkbtn' : (style === 'link' ? 'ppd-linktext' : 'ppd-linkchip'); }
    def('external-link', { label: 'External Link', icon: 'bi-box-arrow-up-right', category: 'Navigation',
        defaults: function () { return { label: 'Open link', href: 'https://', icon: 'bi-link-45deg', style: 'chip', newTab: true, nofollow: false, tooltip: '' }; },
        schema: function () { return [ generalGroup([
            { key: 'label', label: 'Label', type: 'text', hint: 'The clickable text.' },
            { key: 'href', label: 'URL', type: 'text', placeholder: 'https://example.com', hint: 'Where it goes — a full URL (https://…) or a path.' },
            { key: 'icon', label: 'Icon', type: 'icon', hint: 'Icon before the label (clear it for none).' },
            { key: 'style', label: 'Style', type: 'select', options: ['chip', 'button', 'link'], hint: 'chip = bordered pill · button = solid · link = plain text link.' },
            { key: 'newTab', label: 'Open in new tab', type: 'bool', def: true, hint: 'Opens in a new browser tab (adds rel="noopener").' },
            { key: 'nofollow', label: 'Nofollow', type: 'bool', hint: 'Add rel="nofollow" so search engines don’t follow it.' },
            { key: 'tooltip', label: 'Tooltip', type: 'text', hint: 'Hover title text (optional).' }
        ]), styleGroup() ]; },
        render: function (n) {
            var p = n.props || {};
            var ic = p.icon ? icon(p.icon) : '';
            return '<a class="' + linkClass(p.style) + '"' + (p.tooltip ? ' title="' + esc(p.tooltip) + '"' : '') + '>' + ic + '<span' + elAttrs(n, 'label') + '>' + txt(n, 'label', 'Open link') + '</span>' + (p.newTab ? icon('bi-box-arrow-up-right') : '') + '</a>';
        } });
    def('download-link', { label: 'Download Link', icon: 'bi-download', category: 'Navigation',
        defaults: function () { return { label: 'Download', source: 'url', href: '', downloadName: '', table: '', format: 'csv', icon: 'bi-download', style: 'chip' }; },
        schema: function () { return [ generalGroup([
            { key: 'label', label: 'Label', type: 'text', hint: 'The clickable text.' },
            { key: 'source', label: 'Downloads', type: 'radiogroup', options: ['url', 'table export'], rerender: true, hint: 'A file at a URL, or a live export of a table.' },
            { key: 'href', label: 'File URL', type: 'text', when: { key: 'source', in: ['url', ''] }, placeholder: '/files/report.pdf', hint: 'Path/URL of the file to download.' },
            { key: 'downloadName', label: 'Save as (filename)', type: 'text', when: { key: 'source', in: ['url', ''] }, placeholder: 'report.pdf', hint: 'Suggested filename (adds the download attribute). Blank = browser default.' },
            { key: 'table', label: 'Table', type: 'dbtable', when: { key: 'source', in: ['table export'] }, hint: 'Which table to export live.' },
            { key: 'format', label: 'Format', type: 'select', options: ['csv', 'xlsx', 'json', 'xml'], when: { key: 'source', in: ['table export'] }, hint: 'Export file format.' },
            { key: 'icon', label: 'Icon', type: 'icon', hint: 'Icon before the label (clear it for none).' },
            { key: 'style', label: 'Style', type: 'select', options: ['chip', 'button', 'link'], hint: 'chip = bordered pill · button = solid · link = plain text link.' }
        ]), styleGroup() ]; },
        render: function (n) {
            var p = n.props || {};
            var ic = p.icon ? icon(p.icon) : '';
            var badge = p.source === 'table export' && p.table ? ' <i class="bi bi-database ppd-dynbadge" title="Live export of ' + esc(title(p.table)) + '"></i>' : '';
            return '<a class="' + linkClass(p.style) + '">' + ic + '<span' + elAttrs(n, 'label') + '>' + txt(n, 'label', 'Download') + '</span>' + badge + '</a>';
        } });
    def('custom-button', { label: 'Custom Button', icon: 'bi-play-btn', category: 'Actions',
        _desc: 'A button with a 3-way event flow: Client Before (JS) → Server (PHP) → Client After (JS), over the app\'s built-in CSRF-guarded JS↔PHP bridge.',
        defaults: function () { return { label: 'Custom action', ico: 'bi-lightning', variant: 'primary', clientBefore: '', server: '', clientAfter: '' }; },
        schema: function () { return [ generalGroup([
                { key: 'label', label: 'Label', type: 'text' },
                { key: 'ico', label: 'Icon', type: 'icon' },
                { key: 'variant', label: 'Variant', type: 'select', options: ['default', 'primary', 'danger'] },
                { key: 'tooltip', label: 'Tooltip', type: 'text', hint: 'Hover text shown on the button in the generated app.' }
            ]),
            { title: '① Client Before — JavaScript', fields: [
                { key: 'clientBefore', label: 'Runs first, in the browser', type: 'textarea',
                  hint: 'JavaScript that runs when the button is clicked, BEFORE the server call.\nVariables: params (object — put data on it to send to the server), btn (the button element), this (= btn).\nReturn false to cancel the whole action (no server call, no After).\nExample: params.id = 42; if (!confirm(\'Proceed?\')) return false;' }
            ]},
            { title: '② Server — PHP', fields: [
                { key: 'server', label: 'Runs on the server', type: 'textarea',
                  hint: 'PHP that runs on the server (only if this box is non-empty), reached over the built-in CSRF-guarded bridge (api/button.php, login required).\nRead $params (what Client Before put on params); set $result (array — sent back to Client After).\nUse the DB façade: dbAll(), dbRow(), dbValue(), dbExecute() — plus any core helper. Author-trusted, like PHP snippets / Events.\nExample: $result[\'total\'] = dbValue(\'SELECT COUNT(*) FROM orders\'); dbExecute(\'UPDATE orders SET seen=1 WHERE id=?\', [$params[\'id\']]);' }
            ]},
            { title: '③ Client After — JavaScript', fields: [
                { key: 'clientAfter', label: 'Runs last, in the browser', type: 'textarea',
                  hint: 'JavaScript that runs AFTER the server responds.\nVariables: params, result (the $result the server returned), btn, this (= btn).\nExample: alert(\'Orders: \' + result.total); location.reload();' }
            ]},
            styleGroup() ]; },
        render: function (n) {
            var hasFlow = (n.props.clientBefore || n.props.server || n.props.clientAfter);
            return btn({ icon: n.props.ico || 'bi-lightning', label: n.props.label || 'Custom action', displayMode: 'icon_label', variant: n.props.variant }, '')
                + (hasFlow ? '<span class="ppd-cb-flow" title="This button has event actions">' + icon('bi-lightning-charge-fill') + '</span>' : '');
        } });
    function codeRender(lang) { return function (n) { return '<div class="ppd-code"><div class="ppd-code-h">' + icon('bi-code-slash') + '<span>' + esc(lang) + '</span></div><pre class="ppd-code-b">' + esc((n.props && n.props.code) || '// ' + lang + ' snippet') + '</pre></div>'; }; }
    var codeSchema = function () { return [ generalGroup([{ key: 'code', label: 'Code', type: 'textarea' }]), styleGroup() ]; };
    def('html', { label: 'HTML', icon: 'bi-code-slash', category: 'Content', defaults: function () { return { code: '<div>…</div>' }; }, schema: codeSchema, render: codeRender('HTML') });
    def('code-snippet', { label: 'Code Snippet', icon: 'bi-code', category: 'Content', defaults: function () { return { code: '' }; }, schema: codeSchema, render: codeRender('Code') });
    def('php-snippet', { label: 'PHP Snippet', icon: 'bi-filetype-php', category: 'Content', defaults: function () { return { code: '' }; }, schema: codeSchema, render: codeRender('PHP') });
    def('js-snippet', { label: 'JS Snippet', icon: 'bi-filetype-js', category: 'Content', defaults: function () { return { code: '' }; }, schema: codeSchema, render: codeRender('JavaScript') });
    def('css-snippet', { label: 'CSS Snippet', icon: 'bi-filetype-css', category: 'Content', defaults: function () { return { code: '' }; }, schema: codeSchema, render: codeRender('CSS') });
    def('print-panel', { label: 'Print Panel', icon: 'bi-printer', category: 'Content',
        defaults: function () { return {}; }, schema: function () { return [ generalGroup([]), styleGroup() ]; },
        render: function () { return '<div class="ppd-c-buttons">' + btn({ icon: 'bi-printer', label: 'Print', displayMode: 'icon_label' }, '') + btn({ icon: 'bi-file-earmark-pdf', label: 'Save PDF', displayMode: 'icon_label' }, '') + '</div>'; } });

    // Data / search
    def('advanced-search', { label: 'Advanced Search', icon: 'bi-search-heart', category: 'Data',
        defaults: function () { return {}; }, schema: function () { return [ generalGroup([]), styleGroup() ]; },
        render: function (n, ctx) { var fs = ((ctx && ctx.fields) || []).slice(0, 3); return '<div class="ppd-advsearch">' + fs.map(function (f) { return '<span class="ppd-advsearch-f"><small>' + esc(f.label || title(f.name)) + '</small><span class="ppd-c-inputwrap"><input disabled></span></span>'; }).join('') + btn({ icon: 'bi-search', label: 'Search', displayMode: 'icon_label', variant: 'primary' }, '') + '</div>'; } });
    def('filter-panel', { label: 'Filter Panel', icon: 'bi-funnel', category: 'Data',
        defaults: function () { return {}; }, schema: function () { return [ generalGroup([]), styleGroup() ]; },
        render: function (n, ctx) {
            var all = (ctx && ctx.fields) || [];
            var fs = all.filter(function (f) { return f.filterPanel; });   // "Show on Filter Panel"
            if (!fs.length) fs = all.slice(0, 4);                          // fallback preview
            return '<div class="ppd-filterpanel"><div class="ppd-filterpanel-h">' + icon('bi-funnel') + ' Filters</div>' + fs.map(function (f) { return '<label class="ppd-filterpanel-row"><span>' + esc(f.label || title(f.name)) + '</span><span class="ppd-c-inputwrap"><input disabled></span></label>'; }).join('') + '</div>'; } });
    def('totals', { label: 'Totals', icon: 'bi-calculator', category: 'Data',
        defaults: function () { return { items: [{ label: 'Count', value: '204' }, { label: 'Sum', value: '—' }, { label: 'Average', value: '—' }] }; },
        schema: function () { return [ generalGroup([]), styleGroup() ]; },
        render: function (n) { return '<div class="ppd-totals">' + (n.props.items || []).map(function (it) { return '<div class="ppd-total"><small>' + esc(it.label) + '</small><strong>' + esc(it.value) + '</strong></div>'; }).join('') + '</div>'; } });
    def('master-detail', { label: 'Master Detail', icon: 'bi-diagram-3', category: 'Data', container: true,
        defaults: function () { return {}; }, schema: function () { return [ generalGroup([]), styleGroup() ]; },
        render: function (n, ctx) { return '<div class="ppd-masterdetail"><div class="ppd-masterdetail-list">' + ['bi-list', 'bi-list', 'bi-list'].map(function () { return '<div class="ppd-md-row">' + icon('bi-chevron-right') + ' Record</div>'; }).join('') + '</div>' + ctx.renderChildren(n, 'ppd-masterdetail-body') + '</div>'; } });

    // Advanced blocks
    def('timeline', { label: 'Timeline', icon: 'bi-clock', category: 'Advanced',
        defaults: function () { return { source: 'manual', layout: 'left', marker: 'filled', spacing: 'normal', showDate: true, items: [
            { title: 'Milestone 1', text: 'Description of this event', date: 'Jan 2026', icon: '', color: '', link: '' },
            { title: 'Milestone 2', text: 'Description of this event', date: 'Mar 2026', icon: '', color: '', link: '' },
            { title: 'Milestone 3', text: 'Description of this event', date: 'Jun 2026', icon: '', color: '', link: '' }
        ] }; },
        schema: function () { var dyn = function (p) { return (p || {}).source === 'dynamic'; }; return [ generalGroup([
            { key: 'source', label: 'Source', type: 'radiogroup', options: [['manual', 'Manual'], ['dynamic', 'From table']], rerender: true, hint: 'Manual = you type each event. From table = pull events live from the database.' },
            { key: 'items', label: 'Milestones', type: 'list', itemLabel: 'Milestone', addLabel: 'Add milestone', when: function (p) { return !dyn(p); },
              default: { title: 'New milestone', text: '', date: '' },
              item: [
                { key: 'title', label: 'Title', type: 'text' },
                { key: 'text', label: 'Description', type: 'textarea', placeholder: 'Longer text is fine — the box grows.' },
                { key: 'date', label: 'Date / time', type: 'text', placeholder: 'e.g. Jan 2026', hint: 'Shown as a small badge (needs “Show date” on).' },
                { key: 'icon', label: 'Dot icon', type: 'icon' },
                { key: 'color', label: 'Dot color', type: 'color' },
                { key: 'link', label: 'Link (URL)', type: 'text', placeholder: 'https://…', hint: 'Optional — makes the event clickable.' }
              ] },
            { key: 'table', label: 'Table', type: 'dbtable', when: dyn, hint: 'Each row becomes one event on the timeline.' },
            { key: 'titleColumn', label: 'Title column', type: 'dbcolumn', tableKey: 'table', when: dyn },
            { key: 'textColumn', label: 'Description column', type: 'dbcolumn', tableKey: 'table', when: dyn },
            { key: 'dateColumn', label: 'Date column', type: 'dbcolumn', tableKey: 'table', when: dyn, hint: 'Shown as the date badge and used for ordering by default.' },
            { key: 'iconColumn', label: 'Icon column', type: 'dbcolumn', tableKey: 'table', when: dyn, hint: 'Optional — a column holding an icon name per row.' },
            { key: 'where', label: 'Filter (WHERE)', type: 'text', when: dyn, hint: 'Optional SQL condition — supports SQL variables (:userid, :today…).' },
            { key: 'orderBy', label: 'Order by', type: 'dbcolumn', tableKey: 'table', when: dyn, hint: 'Blank = order by the date column.' },
            { key: 'orderDir', label: 'Direction', type: 'select', options: [['asc', 'Oldest first'], ['desc', 'Newest first']], when: dyn },
            { key: 'limit', label: 'Max events', type: 'number', placeholder: '8', when: dyn },
            { key: 'emptyText', label: 'Empty message', type: 'text', placeholder: 'No events yet', when: dyn }
        ]),
        { title: 'Layout', fields: [
            { key: 'layout', label: 'Style', type: 'select', options: [['left', 'Left rail'], ['right', 'Right rail'], ['alternate', 'Alternating']], hint: 'Where the line sits. Alternating zig-zags events left & right.' },
            { key: 'marker', label: 'Marker', type: 'select', options: [['filled', 'Filled dot'], ['outline', 'Outline dot'], ['ring', 'Ring']], hint: 'Node style when no per-event colour is set.' },
            { key: 'cardStyle', label: 'Card style', type: 'bool', hint: 'Wrap each event in a bordered card.' },
            { key: 'showDate', label: 'Show date', type: 'bool', hint: 'Show the date/time badge above each title.' },
            { key: 'spacing', label: 'Spacing', type: 'select', options: [['compact', 'Compact'], ['normal', 'Normal'], ['relaxed', 'Relaxed']] }
        ]},
        { title: 'Line', fields: [
            { key: 'lineColor', label: 'Colour', type: 'color' },
            { key: 'lineWidth', label: 'Width', type: 'text', placeholder: 'e.g. 2px' },
            { key: 'lineStyle', label: 'Style', type: 'select', options: [['solid', 'Solid'], ['dashed', 'Dashed'], ['dotted', 'Dotted']] }
        ]},
        { title: 'Title style', fields: [
            { key: 'titleFont', label: 'Font', type: 'font' },
            { key: 'titleSize', label: 'Font size', type: 'text', placeholder: 'e.g. 15px' },
            { key: 'titleWeight', label: 'Weight', type: 'select', options: ['', 'normal', '500', '600', '700', '800'] },
            { key: 'titleColor', label: 'Colour', type: 'color' }
        ]},
        { title: 'Description style', fields: [
            { key: 'textFont', label: 'Font', type: 'font' },
            { key: 'textSize', label: 'Font size', type: 'text', placeholder: 'e.g. 12px' },
            { key: 'textColor', label: 'Colour', type: 'color' }
        ]},
        { title: 'Marker size', fields: [
            { key: 'dotSize', label: 'Node size', type: 'text', placeholder: 'e.g. 24px' },
            { key: 'iconColor', label: 'Icon colour', type: 'color' }
        ]},
        styleGroup() ]; },
        render: function (n) {
            var p = n.props || {};
            var items = Array.isArray(p.items) ? p.items : [];
            var isDyn = p.source === 'dynamic';
            // Dynamic preview: show representative sample rows so the canvas looks live.
            if (isDyn) { items = [
                { title: (p.titleColumn || 'Title') + ' value', text: (p.textColumn ? p.textColumn + ' value' : 'Row description'), date: p.dateColumn ? '2026-01-01' : '' },
                { title: (p.titleColumn || 'Title') + ' value', text: (p.textColumn ? p.textColumn + ' value' : 'Row description'), date: p.dateColumn ? '2026-02-14' : '' },
                { title: (p.titleColumn || 'Title') + ' value', text: (p.textColumn ? p.textColumn + ' value' : 'Row description'), date: p.dateColumn ? '2026-03-30' : '' }
            ]; }
            // Legacy pages stored only a `count`; synthesize placeholder rows.
            else if (!items.length && p.count) { var c = Math.max(1, Math.min(8, parseInt(p.count, 10) || 3)); items = []; for (var k = 0; k < c; k++) items.push({ title: 'Milestone ' + (k + 1), text: 'Description of this event' }); }
            var tS = (p.titleFont ? 'font-family:' + p.titleFont + ';' : '') + (p.titleSize ? 'font-size:' + p.titleSize + ';' : '') + (p.titleWeight ? 'font-weight:' + p.titleWeight + ';' : '') + (p.titleColor ? 'color:' + p.titleColor + ';' : '');
            var dS = (p.textFont ? 'font-family:' + p.textFont + ';' : '') + (p.textSize ? 'font-size:' + p.textSize + ';' : '') + (p.textColor ? 'color:' + p.textColor + ';' : '');
            var cls = 'ppd-timeline' + (p.layout === 'right' ? ' tl-right' : p.layout === 'alternate' ? ' tl-alt' : '')
                + (p.marker === 'outline' ? ' mk-outline' : p.marker === 'ring' ? ' mk-ring' : '')
                + (p.cardStyle ? ' tl-card' : '') + (p.spacing === 'compact' ? ' sp-compact' : p.spacing === 'relaxed' ? ' sp-relaxed' : '');
            var lv = (p.lineColor ? '--tl-lc:' + p.lineColor + ';' : '') + (p.lineWidth ? '--tl-lw:' + p.lineWidth + ';' : '') + (p.lineStyle ? '--tl-ls:' + p.lineStyle + ';' : '');
            var showDate = p.showDate !== false;
            return '<div class="' + cls + '"' + (lv ? ' style="' + esc(lv) + '"' : '') + '>' + items.map(function (it) {
                it = it || {};
                var dot = '';
                if (it.color) dot += 'background:' + it.color + ';border-color:' + it.color + ';';
                if (p.dotSize) dot += 'width:' + p.dotSize + ';height:' + p.dotSize + ';';
                if (p.iconColor && it.icon) dot += 'color:' + p.iconColor + ';';
                var dotAttr = dot ? ' style="' + esc(dot) + '"' : '';
                var ic = it.icon ? '<span class="ppd-tl-dot has-ic"' + dotAttr + '>' + icon(it.icon) + '</span>' : '<span class="ppd-tl-dot"' + dotAttr + '></span>';
                var date = (showDate && it.date) ? '<span class="ppd-tl-date">' + esc(it.date) + '</span>' : '';
                var body = '<div class="ppd-tl-c">' + date + '<strong' + (tS ? ' style="' + esc(tS) + '"' : '') + '>' + esc(it.title || '') + '</strong><small' + (dS ? ' style="' + esc(dS) + '"' : '') + '>' + esc(it.text || '') + '</small></div>';
                if (it.link) body = '<a class="ppd-tl-link" href="' + esc(it.link) + '">' + body + '</a>';
                return '<div class="ppd-tl-row">' + ic + body + '</div>';
            }).join('') + '</div>';
        } });
    def('kanban', { label: 'Kanban', icon: 'bi-kanban', category: 'Advanced',
        defaults: function () { return {
            source: 'manual', layout: 'fit', columnStyle: 'soft', cardStyle: 'flat', countBadge: true,
            board: [
                { title: 'To do', color: '#64748b', cards: 'Design homepage | Landing page | High\nWrite launch copy' },
                { title: 'In progress', color: '#f59e0b', cards: 'Build REST API | Backend' },
                { title: 'Done', color: '#22c55e', cards: 'Project kickoff | Complete' }
            ],
            columns: ['To do', 'In progress', 'Done']
        }; },
        schema: function () {
            var dyn = function (p) { return p.source === 'dynamic' || (!p.source && !!p.table); };
            var man = function (p) { return !dyn(p); };
            return [ generalGroup([
                { key: 'source', label: 'Source', type: 'radiogroup', options: [['manual', 'Manual'], ['dynamic', 'From table']], rerender: true, hint: 'Manual = design the board by hand. From table = one card per row, grouped into columns.' },
                { key: 'board', label: 'Columns', type: 'list', itemLabel: 'Column', addLabel: 'Add column', when: man,
                  default: { title: 'New column', color: '', cards: '' },
                  item: [
                    { key: 'title', label: 'Column title', type: 'text' },
                    { key: 'color', label: 'Accent colour', type: 'color', hint: 'Top bar of this column.' },
                    { key: 'cards', label: 'Cards', type: 'textarea', placeholder: 'One card per line.\nUse | to split: Title | Subtitle | Badge | Date', hint: 'Each line is a card. Optionally split with “|” into Title | Subtitle | Badge | Date.' }
                  ] },
                { key: 'table', label: 'Table', type: 'dbtable', when: dyn, hint: 'Each row becomes one card.' },
                { key: 'groupColumn', label: 'Group by (→ columns)', type: 'dbcolumn', tableKey: 'table', when: dyn, hint: 'Each distinct value in this column becomes a board column.' },
                { key: 'titleColumn', label: 'Card title', type: 'dbcolumn', tableKey: 'table', when: dyn },
                { key: 'descColumn', label: 'Card subtitle', type: 'dbcolumn', tableKey: 'table', when: dyn },
                { key: 'badgeColumn', label: 'Card badge', type: 'dbcolumn', tableKey: 'table', when: dyn, hint: 'Small tag on each card (priority, category…).' },
                { key: 'dateColumn', label: 'Card date', type: 'dbcolumn', tableKey: 'table', when: dyn },
                { key: 'where', label: 'Filter (WHERE)', type: 'text', when: dyn, hint: 'Optional SQL condition — supports :userid, :today…' },
                { key: 'orderBy', label: 'Order cards by', type: 'dbcolumn', tableKey: 'table', when: dyn },
                { key: 'orderDir', label: 'Direction', type: 'select', options: [['asc', 'Ascending'], ['desc', 'Descending']], when: dyn },
                { key: 'columns', label: 'Fixed column order', type: 'csv', when: dyn, note: 'Comma-separated. Blank = columns discovered from the data.' },
                { key: 'limit', label: 'Cards per column', type: 'number', placeholder: '8', when: dyn },
                { key: 'emptyText', label: 'Empty column text', type: 'text', placeholder: '—', when: dyn }
            ]),
            { title: 'Layout & style', fields: [
                { key: 'layout', label: 'Overflow', type: 'select', options: [['fit', 'Fit width'], ['scroll', 'Scroll'], ['wrap', 'Wrap']], hint: 'Fit = squeeze all columns · Scroll = fixed width, scroll sideways · Wrap = flow onto rows.' },
                { key: 'columnWidth', label: 'Column width', type: 'text', placeholder: 'e.g. 200px' },
                { key: 'columnStyle', label: 'Column style', type: 'select', options: [['soft', 'Soft'], ['plain', 'Plain'], ['bordered', 'Bordered']] },
                { key: 'cardStyle', label: 'Card style', type: 'select', options: [['flat', 'Flat'], ['raised', 'Raised'], ['outline', 'Outline']] },
                { key: 'accent', label: 'Accent colour', type: 'color', hint: 'Default column top-bar + badge colour.' },
                { key: 'compact', label: 'Compact cards', type: 'bool' },
                { key: 'countBadge', label: 'Show count', type: 'bool', hint: 'Card count in each column header.' },
                { key: 'dragDrop', label: 'Drag & drop', type: 'bool', when: dyn, hint: 'Let users drag cards between columns — the record’s group value is saved instantly (dynamic mode).' }
            ]},
            styleGroup() ];
        },
        render: function (n) {
            var p = n.props || {};
            var isDyn = p.source === 'dynamic' || (!p.source && !!p.table);
            var cls = 'ppd-kanban'
                + (p.layout === 'scroll' ? ' kb-scroll' : p.layout === 'wrap' ? ' kb-wrap' : '')
                + (p.columnStyle === 'plain' ? ' kb-col-plain' : p.columnStyle === 'bordered' ? ' kb-col-bordered' : '')
                + (p.cardStyle === 'raised' ? ' kb-card-raised' : p.cardStyle === 'outline' ? ' kb-card-outline' : '')
                + (p.compact ? ' kb-compact' : '')
                + (isDyn && p.dragDrop ? ' kb-dnd' : '');
            var cv = (p.accent ? '--kb-accent:' + p.accent + ';' : '') + (p.columnWidth ? '--kb-colw:' + p.columnWidth + ';' : '');
            var open = '<div class="' + cls + '"' + (cv ? ' style="' + esc(cv) + '"' : '') + '>';
            function card(title, sub, badge, date) {
                var f = (badge ? '<span class="ppd-kanban-badge">' + esc(badge) + '</span>' : '') + (date ? '<span class="ppd-kanban-date">' + esc(date) + '</span>' : '');
                return '<div class="ppd-kanban-card"><div class="ppd-kanban-ct">' + esc(title) + '</div>'
                    + (sub ? '<div class="ppd-kanban-cs">' + esc(sub) + '</div>' : '')
                    + (f ? '<div class="ppd-kanban-cf">' + f + '</div>' : '') + '</div>';
            }
            if (isDyn) {
                var cols = (Array.isArray(p.columns) && p.columns.length) ? p.columns : ['To do', 'In progress', 'Done'];
                return open + cols.map(function (c) {
                    var count = p.countBadge ? '<span class="ppd-kanban-count">2</span>' : '';
                    var sample = card((p.titleColumn || 'Title') + ' value', p.descColumn ? p.descColumn + ' value' : '', p.badgeColumn ? 'Badge' : '', p.dateColumn ? '2026-01-01' : '');
                    return '<div class="ppd-kanban-col"><div class="ppd-kanban-h">' + esc(c) + count + '</div>' + sample + sample + '</div>';
                }).join('') + '</div>';
            }
            var board = Array.isArray(p.board) ? p.board : [];
            if (!board.length) board = [{ title: 'To do', cards: '' }, { title: 'In progress', cards: '' }, { title: 'Done', cards: '' }];
            return open + board.map(function (col) {
                col = col || {};
                var accent = col.color ? ' style="--kb-caccent:' + esc(col.color) + '"' : '';
                var lines = String(col.cards || '').split(/\r?\n/).map(function (l) { return l.trim(); }).filter(Boolean);
                var count = p.countBadge ? '<span class="ppd-kanban-count">' + lines.length + '</span>' : '';
                var body = lines.length ? lines.map(function (l) { var pt = l.split('|').map(function (s) { return s.trim(); }); return card(pt[0] || '', pt[1] || '', pt[2] || '', pt[3] || ''); }).join('') : '<div class="ppd-kanban-card is-empty">Empty</div>';
                return '<div class="ppd-kanban-col"' + accent + '><div class="ppd-kanban-h">' + esc(col.title || '') + count + '</div>' + body + '</div>';
            }).join('') + '</div>';
        } });
    def('calendar', { label: 'Calendar', icon: 'bi-calendar3', category: 'Advanced',
        defaults: function () { return { showHeader: true, showWeekdays: true, weekStart: 'sunday', markStyle: 'fill', size: 'md', cellShape: 'rounded', highlightToday: true, weekendDays: ['0', '6'], calendarType: 'english', calendarDefault: 'nepali', shadeWeekends: false }; },
        schema: function () { return [ generalGroup([
            { key: 'table', label: 'Table (mark days with records)', type: 'dbtable' },
            { key: 'dateColumn', label: 'Date column', type: 'dbcolumn', tableKey: 'table' },
            { key: 'where', label: 'Filter (WHERE)', type: 'text', hint: 'Optional — only count matching rows. Supports :userid, :today…' },
            { key: 'month', label: 'Month (1–12)', type: 'number', placeholder: 'current' },
            { key: 'year', label: 'Year', type: 'number', placeholder: 'current' }
        ]),
        { title: 'Header & labels', fields: [
            { key: 'showHeader', label: 'Show month title', type: 'bool' },
            { key: 'headerText', label: 'Title override', type: 'text', placeholder: 'e.g. August 2026' },
            { key: 'showWeekdays', label: 'Show weekday labels', type: 'bool' },
            { key: 'weekStart', label: 'Week starts', type: 'select', options: [['sunday', 'Sunday'], ['monday', 'Monday']] }
        ]},
        { title: 'Marks & style', fields: [
            { key: 'markStyle', label: 'Marked day', type: 'select', options: [['fill', 'Fill'], ['dot', 'Dot'], ['ring', 'Ring'], ['badge', 'Count badge']], hint: 'How days that have records are shown.' },
            { key: 'accent', label: 'Today & accent colour', type: 'color', hint: 'Today’s highlight + weekday labels. Blank = your theme’s primary colour.' },
            { key: 'eventColor', label: 'Event day colour', type: 'color', hint: 'Days that have records — their tint, dot/ring and chips. Blank = follows the accent colour.' },
            { key: 'highlightToday', label: 'Highlight today', type: 'bool' },
            { key: 'shadeWeekends', label: 'Shade weekends', type: 'bool', rerender: true },
            { key: 'weekendDays', label: 'Weekend days', type: 'multiselect', when: function (p) { return !!p.shadeWeekends; }, options: [['0', 'Sun'], ['1', 'Mon'], ['2', 'Tue'], ['3', 'Wed'], ['4', 'Thu'], ['5', 'Fri'], ['6', 'Sat']], hint: 'Which days count as the weekend (varies by country). Blank = Saturday & Sunday.' },
            { key: 'weekendColor', label: 'Weekend colour', type: 'color', when: function (p) { return !!p.shadeWeekends; }, hint: 'Shade colour for weekend days. Blank = a subtle accent tint.' },
            { key: 'size', label: 'Cell size', type: 'select', options: [['sm', 'Small'], ['md', 'Medium'], ['lg', 'Large']] },
            { key: 'cellShape', label: 'Cell shape', type: 'select', options: [['rounded', 'Rounded'], ['square', 'Square'], ['circle', 'Circle']] },
            { key: 'maxWidth', label: 'Max width', type: 'text', placeholder: 'e.g. 360px (blank = full)', hint: 'Constrain the calendar width — handy inside cards, sidebars or modals.' },
            { key: 'align', label: 'Align', type: 'select', options: [['left', 'Left'], ['center', 'Center'], ['right', 'Right']], hint: 'Position when a max width is set.' }
        ]},
        { title: 'Nepali (BS) & event details', fields: [
            { key: 'calendarType', label: 'Calendar system', type: 'select', options: [['english', 'English (AD)'], ['nepali', 'Nepali (BS)'], ['both', 'Both — AD/ने toggle']], hint: 'Gregorian, Bikram Sambat, or a live in-app toggle between them.' },
            { key: 'calendarDefault', label: 'Default view', type: 'select', options: [['english', 'English (AD)'], ['nepali', 'Nepali (BS)']], when: function (p) { return p.calendarType === 'both'; }, hint: 'Which calendar shows first when both are available.' },
            { key: 'titleColumn', label: 'Event title column', type: 'dbcolumn', tableKey: 'table', hint: 'Shown as each event chip + the detail panel title.' },
            { key: 'detailColumns', label: 'Detail fields', type: 'text', hint: 'Fields in the click-to-open panel — "column:Label" pairs, comma-separated. e.g. plaintiff:Plaintiff, court_name:Court' },
            { key: 'keyColumn', label: 'Record key column', type: 'dbcolumn', tableKey: 'table', hint: 'Primary key for the panel’s “Open” link (default id).' },
            { key: 'linkTable', label: '“Open” → table', type: 'dbtable', hint: 'The table whose view page the “Open” link points to.' }
        ]},
        styleGroup() ]; },
        render: function (n) {
            var p = n.props || {}, MON = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
            var now = new Date();
            var mo = (parseInt(p.month, 10) >= 1 && parseInt(p.month, 10) <= 12) ? parseInt(p.month, 10) : (now.getMonth() + 1);
            var yr = (parseInt(p.year, 10) > 0) ? parseInt(p.year, 10) : now.getFullYear();
            var ws = p.weekStart === 'monday' ? 1 : 0;
            var first = new Date(yr, mo - 1, 1).getDay(), dim = new Date(yr, mo, 0).getDate();
            var off = ws === 1 ? ((first + 6) % 7) : first;
            var isCur = (mo === now.getMonth() + 1 && yr === now.getFullYear());
            var td = (p.highlightToday !== false && isCur) ? now.getDate() : -1;
            var marks = {}; if (p.table && p.dateColumn) marks = { 4: 2, 11: 1, 18: 3, 25: 1 };
            var cls = 'ppd-mcal-wrap' + (p.size === 'sm' ? ' cal-sm' : p.size === 'lg' ? ' cal-lg' : '')
                + (p.cellShape === 'square' ? ' cal-square' : p.cellShape === 'circle' ? ' cal-circle' : '')
                + (['dot', 'ring', 'badge'].indexOf(p.markStyle) > -1 ? ' mk-' + p.markStyle : '');
            var wkSet = Array.isArray(p.weekendDays) ? p.weekendDays.map(Number) : [0, 6];
            var sv = (p.accent ? '--mcal-accent:' + p.accent + ';' : '')
                + (p.eventColor ? '--mcal-event:' + p.eventColor + ';' : '')
                + (p.shadeWeekends && p.weekendColor ? '--mcal-weekend:' + p.weekendColor + ';' : '')
                + (p.maxWidth ? 'max-width:' + p.maxWidth + ';' + (p.align === 'center' ? 'margin:0 auto;' : p.align === 'right' ? 'margin-left:auto;' : '') : '');
            // Mode pill mirrors the built app's calendar system (the canvas always
            // draws the AD grid; the generated app renders Bikram Sambat when asked).
            var calT = p.calendarType || 'english';
            var modePill = '';
            if (calT === 'nepali') modePill = '<span class="ppd-mcal-mode" title="Generated app shows the Bikram Sambat month">ने BS</span>';
            else if (calT === 'both') modePill = '<span class="ppd-mcal-mode" title="Generated app shows an AD/BS toggle; default: ' + esc(p.calendarDefault || 'english') + '">AD·ने</span>';
            var head = (p.showHeader !== false) ? '<div class="ppd-mcal-head">' + esc((p.headerText || '').trim() || (MON[mo - 1] + ' ' + yr)) + modePill + '</div>' : '';
            var wd = ws === 1 ? ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] : ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
            var wdRow = (p.showWeekdays !== false) ? '<div class="ppd-mcal-wd">' + wd.map(function (w) { return '<span>' + w + '</span>'; }).join('') + '</div>' : '';
            var cells = '';
            for (var b = 0; b < off; b++) cells += '<span class="ppd-mcal-day is-blank"></span>';
            for (var i = 1; i <= dim; i++) {
                var dow = (first + i - 1) % 7, wknd = wkSet.indexOf(dow) > -1, has = !!marks[i];
                var c2 = 'ppd-mcal-day' + (i === td ? ' is-active' : '') + (has ? ' has-rec' : '') + (p.shadeWeekends && wknd ? ' is-weekend' : '');
                cells += '<span class="' + c2 + '">' + i + (p.markStyle === 'badge' && has ? '<span class="ppd-mcal-badge">' + marks[i] + '</span>' : '') + '</span>';
            }
            return '<div class="' + cls + '"' + (sv ? ' style="' + esc(sv) + '"' : '') + '>' + head + wdRow + '<div class="ppd-mcal">' + cells + '</div></div>';
        } });
    def('custom-map', { label: 'Custom Map', icon: 'bi-geo-alt', category: 'Advanced',
        defaults: function () { return { provider: 'google', address: 'Kathmandu, Nepal', zoom: 13, height: '320px', marker: true, rounded: true, border: true, mapType: 'roadmap' }; },
        schema: function () {
            var g = function (p) { return (p.provider || 'google') === 'google'; };
            return [ generalGroup([
                { key: 'provider', label: 'Map provider', type: 'radiogroup', options: [['google', 'Google'], ['osm', 'OpenStreetMap']], rerender: true, hint: 'Google takes an address OR coordinates (no key). OpenStreetMap is privacy-friendly and uses coordinates.' },
                { key: 'address', label: 'Address / place', type: 'text', when: g, placeholder: 'e.g. Eiffel Tower, Paris', hint: 'Any address or place name. Leave blank to use the coordinates below.' },
                { key: 'lat', label: 'Latitude', type: 'text', placeholder: 'e.g. 27.7172' },
                { key: 'lng', label: 'Longitude', type: 'text', placeholder: 'e.g. 85.3240' },
                { key: 'zoom', label: 'Zoom', type: 'number', placeholder: '13', hint: '1 = world · 13 = city · 17 = street.' },
                { key: 'mapType', label: 'Map type', type: 'select', options: [['roadmap', 'Road'], ['satellite', 'Satellite']], when: g },
                { key: 'marker', label: 'Show marker', type: 'bool' }
            ]),
            { title: 'Appearance', fields: [
                { key: 'height', label: 'Height', type: 'text', placeholder: 'e.g. 320px' },
                { key: 'rounded', label: 'Rounded corners', type: 'bool' },
                { key: 'border', label: 'Border', type: 'bool' },
                { key: 'caption', label: 'Caption', type: 'text', placeholder: 'Optional text under the map' }
            ]},
            styleGroup() ];
        },
        render: function (n) {
            var p = n.props || {};
            var prov = p.provider === 'osm' ? 'osm' : 'google';
            var lat = String(p.lat == null ? '' : p.lat).trim(), lng = String(p.lng == null ? '' : p.lng).trim();
            var addr = String(p.address == null ? '' : p.address).trim();
            var zoom = Math.max(1, Math.min(20, parseInt(p.zoom, 10) || 13));
            var url = '';
            if (prov === 'google') {
                var q = addr || (lat && lng ? lat + ',' + lng : '');
                if (q) url = 'https://maps.google.com/maps?q=' + encodeURIComponent(q) + '&z=' + zoom + '&t=' + (p.mapType === 'satellite' ? 'k' : 'm') + '&hl=en&output=embed';
            } else if (lat && lng && !isNaN(+lat) && !isNaN(+lng)) {
                var d = 0.5 * Math.pow(2, 13 - zoom);
                var bbox = (+lng - d) + ',' + (+lat - d) + ',' + (+lng + d) + ',' + (+lat + d);
                url = 'https://www.openstreetmap.org/export/embed.html?bbox=' + encodeURIComponent(bbox) + '&layer=mapnik' + (p.marker !== false ? '&marker=' + encodeURIComponent(lat + ',' + lng) : '');
            }
            var h = String(p.height || '320px');
            if (!url) return '<div class="ppd-media ppd-map" style="min-height:' + esc(h) + '">' + icon('bi-geo-alt') + '<span>Set an address or coordinates</span></div>';
            var boxCls = 'ppd-map-box' + (p.rounded === false ? ' no-round' : '') + (p.border ? ' has-border' : '');
            var cap = (p.caption || '').trim() ? '<div class="ppd-map-cap">' + esc(p.caption) + '</div>' : '';
            return '<div class="' + boxCls + '"><iframe class="ppd-map-embed" src="' + esc(url) + '" style="height:' + esc(h) + '" loading="lazy" referrerpolicy="no-referrer-when-downgrade"></iframe></div>' + cap;
        } });

    // Polished generic placeholder for any remaining catalogue component
    function genericRender(n) {
        var d = reg[n.type] || {};
        return '<div class="ppd-c-generic"><span class="ppd-c-generic-ic">' + icon(d.icon || 'bi-box') + '</span>' +
            '<div class="ppd-c-generic-meta"><strong>' + esc(n.label || d.label || title(n.type)) + '</strong>' +
            '<small>' + esc(d._desc || title(n.type)) + '</small></div>' +
            '<span class="ppd-c-generic-badge">' + esc(d.category || 'Component') + '</span></div>';
    }
    function genericSchema() { return [ generalGroup([{ key: 'text', label: 'Text', type: 'text' }]), styleGroup() ]; }

    // Ingest the backend catalogue: register any type we do not already render.
    (function ingestCatalogue() {
        var cat = (window.PpDesignerBootstrap && window.PpDesignerBootstrap.components) || [];
        if (!Array.isArray(cat)) return;
        cat.forEach(function (c) {
            if (!c || !c.type) return;
            if (reg[c.type]) { if (!reg[c.type]._desc && c.description) reg[c.type]._desc = c.description; return; }
            var dflt = c.defaults && typeof c.defaults === 'object' ? c.defaults : {};
            def(c.type, {
                label: c.label || title(c.type),
                icon: iconCls(c.icon || 'box'),
                category: c.category || 'Components',
                _desc: c.description || '',
                defaults: function () { return JSON.parse(JSON.stringify(dflt)); },
                render: genericRender,
                schema: genericSchema
            });
        });
    })();

    /* ---------------- fallback ---------------- */
    reg._fallback = {
        label: 'Component', icon: 'bi-box', category: 'Other',
        defaults: function () { return {}; },
        render: function (n) { return '<div class="ppd-c-text">' + icon('bi-box') + ' ' + esc(n.label || n.type) + ' <small style="color:#94a3b8">(' + esc(n.type) + ')</small></div>'; },
        schema: function () { return [ generalGroup([]), styleGroup() ]; }
    };

    // palette grouping for the left panel
    function palette() {
        var groups = {};
        Object.keys(reg).forEach(function (t) {
            if (t === '_fallback') return;
            var d = reg[t];
            if (t === 'grid') return; // alias of table-grid
            if (d.hidden) return;     // special components bound to a specific page
            (groups[d.category] = groups[d.category] || []).push({ type: t, label: d.label, icon: d.icon, hot: !!d.hot });
        });
        // `hot` components float to the top of their category (stable sort keeps
        // the rest in registration order).
        Object.keys(groups).forEach(function (cat) { groups[cat].sort(function (a, b) { return (b.hot ? 1 : 0) - (a.hot ? 1 : 0); }); });
        return groups;
    }

    /* ---- Cell Grid structural ops (pure — no DOM, unit-testable) ----------
       Each structural op takes the grid PROPS ({rows, cols, cells}) and returns
       the new {rows, cols, cells} plus a remap(tab)→tab for the children (−1 =
       the child's cell vanished; caller re-homes it to cell 0). Merge ops
       return {cells, fromTab, toTab} so the caller can move the absorbed
       cell's children into the master. All ops leave the input untouched. */
    var gridOps = (function () {
        // Hard ceiling of the grid. BOTH renderers clamp to these (canvas above,
        // ComponentRenderer in the build), so letting the model grow past them
        // silently hid rows — and any field parked there vanished from the page
        // AND from the generated app while still sitting in the saved design.
        // Enforced here, at the only place that grows a grid, so model and
        // render can never disagree.
        var MAX_ROWS = 20, MAX_COLS = 12;
        function dims(p) { return { R: Math.max(1, parseInt(p.rows, 10) || 1), C: Math.max(1, parseInt(p.cols, 10) || 1) }; }
        function cloneCells(cells) { var o = {}; Object.keys(cells || {}).forEach(function (k) { o[k] = Object.assign({}, cells[k]); }); return o; }
        function rcOf(tab, C) { tab = typeof tab === 'number' ? tab : 0; return { r: Math.floor(tab / C), c: tab % C }; }
        function spliceCsv(csv, at, C, del) {
            var s = String(csv || '').trim(); if (s === '') return '';
            var t = s.split(',').map(function (x) { return x.trim(); });
            while (t.length < C) t.push('');
            if (del) t.splice(at, 1); else t.splice(at, 0, '');
            return t.join(',');
        }
        return {
            covered: lgCovered,
            MAX_ROWS: MAX_ROWS, MAX_COLS: MAX_COLS,
            insertRow: function (p, at) {
                var d = dims(p), out = {};
                if (d.R >= MAX_ROWS) return null;          // refuse: rows past this never render
                Object.keys(p.cells || {}).forEach(function (k) {
                    var a = k.split(':'), r = +a[0], c = +a[1];
                    var cfg = Object.assign({}, p.cells[k]);
                    var rs = Math.max(1, cfg.rs || 1);
                    if (r >= at) out[(r + 1) + ':' + c] = cfg;
                    else { if (r + rs > at) cfg.rs = rs + 1; out[k] = cfg; }
                });
                return { rows: d.R + 1, cols: d.C, cells: out, remap: function (t) {
                    var x = rcOf(t, d.C); return (x.r >= at ? x.r + 1 : x.r) * d.C + x.c; } };
            },
            insertCol: function (p, at) {
                var d = dims(p), out = {};
                if (d.C >= MAX_COLS) return null;          // refuse: 12-column ceiling
                Object.keys(p.cells || {}).forEach(function (k) {
                    var a = k.split(':'), r = +a[0], c = +a[1];
                    var cfg = Object.assign({}, p.cells[k]);
                    var cs = Math.max(1, cfg.cs || 1);
                    if (c >= at) out[r + ':' + (c + 1)] = cfg;
                    else { if (c + cs > at) cfg.cs = cs + 1; out[k] = cfg; }
                });
                return { rows: d.R, cols: d.C + 1, cells: out, colWidths: spliceCsv(p.colWidths, at, d.C, false), remap: function (t) {
                    var x = rcOf(t, d.C); return x.r * (d.C + 1) + (x.c >= at ? x.c + 1 : x.c); } };
            },
            deleteRow: function (p, at) {
                var d = dims(p); if (d.R <= 1) return null;
                var out = {};
                Object.keys(p.cells || {}).forEach(function (k) {
                    var a = k.split(':'), r = +a[0], c = +a[1];
                    var cfg = Object.assign({}, p.cells[k]);
                    var rs = Math.max(1, cfg.rs || 1);
                    if (r === at) { if (rs > 1) { cfg.rs = rs - 1; out[r + ':' + c] = cfg; } return; }
                    if (r > at) { out[(r - 1) + ':' + c] = cfg; return; }
                    if (r + rs > at) cfg.rs = rs - 1;
                    out[k] = cfg;
                });
                return { rows: d.R - 1, cols: d.C, cells: out, remap: function (t) {
                    var x = rcOf(t, d.C); if (x.r === at) return -1;
                    return (x.r > at ? x.r - 1 : x.r) * d.C + x.c; } };
            },
            deleteCol: function (p, at) {
                var d = dims(p); if (d.C <= 1) return null;
                var out = {};
                Object.keys(p.cells || {}).forEach(function (k) {
                    var a = k.split(':'), r = +a[0], c = +a[1];
                    var cfg = Object.assign({}, p.cells[k]);
                    var cs = Math.max(1, cfg.cs || 1);
                    if (c === at) { if (cs > 1) { cfg.cs = cs - 1; out[r + ':' + c] = cfg; } return; }
                    if (c > at) { out[r + ':' + (c - 1)] = cfg; return; }
                    if (c + cs > at) cfg.cs = cs - 1;
                    out[k] = cfg;
                });
                return { rows: d.R, cols: d.C - 1, cells: out, colWidths: spliceCsv(p.colWidths, at, d.C, true), remap: function (t) {
                    var x = rcOf(t, d.C); if (x.c === at) return -1;
                    return x.r * (d.C - 1) + (x.c > at ? x.c - 1 : x.c); } };
            },
            mergeRight: function (p, r, c) {
                var d = dims(p), cells = p.cells || {}, key = r + ':' + c;
                var cfg = Object.assign({}, cells[key] || {});
                var rs = Math.max(1, cfg.rs || 1), cs = Math.max(1, cfg.cs || 1);
                var tc = c + cs; if (tc >= d.C) return null;
                var tKey = r + ':' + tc, tCfg = cells[tKey] || {};
                if (lgCovered(p)[tKey]) return null;                       // not a master
                if (Math.max(1, tCfg.rs || 1) !== rs) return null;         // must be rectangular
                var out = cloneCells(cells);
                cfg.cs = cs + Math.max(1, tCfg.cs || 1); out[key] = cfg; delete out[tKey];
                return { cells: out, fromTab: r * d.C + tc, toTab: r * d.C + c };
            },
            mergeDown: function (p, r, c) {
                var d = dims(p), cells = p.cells || {}, key = r + ':' + c;
                var cfg = Object.assign({}, cells[key] || {});
                var rs = Math.max(1, cfg.rs || 1), cs = Math.max(1, cfg.cs || 1);
                var tr = r + rs; if (tr >= d.R) return null;
                var tKey = tr + ':' + c, tCfg = cells[tKey] || {};
                if (lgCovered(p)[tKey]) return null;
                if (Math.max(1, tCfg.cs || 1) !== cs) return null;
                var out = cloneCells(cells);
                cfg.rs = rs + Math.max(1, tCfg.rs || 1); out[key] = cfg; delete out[tKey];
                return { cells: out, fromTab: tr * d.C + c, toTab: r * d.C + c };
            },
            unmerge: function (p, r, c) {
                var out = cloneCells(p.cells || {}), key = r + ':' + c;
                out[key] = Object.assign({}, out[key] || {}); out[key].rs = 1; out[key].cs = 1;
                return { cells: out };
            },
            // Merge a ⌘-click multi-selection: the bounding rectangle of the given
            // master cells collapses into its top-left cell. Plain masters that fall
            // inside the box are consumed too (diagonal picks work); a merge whose
            // box would cut across an outside merged cell is refused.
            mergeRect: function (p, keys) {
                var d = dims(p), cells = p.cells || {}, cov = lgCovered(p);
                var list = (keys || []).filter(function (k, i, a) {
                    return /^\d+:\d+$/.test(k) && a.indexOf(k) === i && !cov[k];
                });
                if (list.length < 2) return null;
                var r0 = Infinity, c0 = Infinity, r1 = -1, c1 = -1;
                list.forEach(function (k) {
                    var a = k.split(':'), r = +a[0], c = +a[1], cfg = cells[k] || {};
                    r0 = Math.min(r0, r); c0 = Math.min(c0, c);
                    r1 = Math.max(r1, r + Math.max(1, parseInt(cfg.rs, 10) || 1) - 1);
                    c1 = Math.max(c1, c + Math.max(1, parseInt(cfg.cs, 10) || 1) - 1);
                });
                if (r1 >= d.R || c1 >= d.C || (r0 === r1 && c0 === c1)) return null;
                var fromTabs = [];
                for (var r = 0; r < d.R; r++) for (var c = 0; c < d.C; c++) {
                    if (cov[r + ':' + c]) continue;            // swallowed → not a master
                    var cfg = cells[r + ':' + c] || {};
                    var er = r + Math.max(1, parseInt(cfg.rs, 10) || 1) - 1;
                    var ec = c + Math.max(1, parseInt(cfg.cs, 10) || 1) - 1;
                    var overlaps = r <= r1 && er >= r0 && c <= c1 && ec >= c0;
                    if (!overlaps) continue;
                    var inside = r >= r0 && er <= r1 && c >= c0 && ec <= c1;
                    if (!inside) return null;                  // crosses the box boundary
                    if (r !== r0 || c !== c0) fromTabs.push(r * d.C + c);
                }
                var out = cloneCells(cells);
                fromTabs.forEach(function (t) { delete out[Math.floor(t / d.C) + ':' + (t % d.C)]; });
                var anchor = Object.assign({}, out[r0 + ':' + c0] || {});
                anchor.rs = r1 - r0 + 1; anchor.cs = c1 - c0 + 1;
                out[r0 + ':' + c0] = anchor;
                return { cells: out, fromTabs: fromTabs, toTab: r0 * d.C + c0 };
            }
        };
    })();

    PPD.registry = { get: get, all: all, def: def, palette: palette, esc: esc, title: title, icon: icon, ICONS: ICONS, META: META, resolve: resolve, fontChoices: FONT_CHOICES, controlDefaultsFor: controlDefaultsFor, mdPrevCols: mdPrevCols, mdBadgeCss: mdBadgeCss, dgCols: dgCols, relCols: relCols, gridOps: gridOps };
})();
