/* ============================================================
   PPphp Designer — Property Inspector (schema-driven)
   Renders properties for the selected component (via registry
   schema) or the active page when nothing is selected.
   Edits are live: every input writes through model.* mutations.
   ============================================================ */
(function () {
    'use strict';
    var PPD = window.PPD = window.PPD || {};
    var store = PPD.store, model = PPD.model, reg = PPD.registry;
    var esc = reg.esc, title = reg.title;

    function qs(s, r) { return (r || document).querySelector(s); }
    function qsa(s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); }

    var lastInspSel = null;   // remembers the last-rendered selection (for scroll preservation)
    function render() {
        var host = qs('#ppdInspector');
        if (!host) return;
        var page = model.activePage();
        var selId = store.ui('selectedId');
        var selPart = store.ui('selectedPart');
        var hit = page && selId ? model.find(page, selId) : null;

        // Preserve the panel's scroll across re-renders of the SAME selection
        // (adding a rule, toggling a control, etc. rebuild host.innerHTML and
        // would otherwise snap the panel back to the top). A genuine selection
        // change still resets to the top.
        var curSel = (page ? (page.id || page.type || '') : 'none') + '|' + (selId || '') + '|' + (selPart || '');
        var prevBody = qs('.ppd-insp-body', host);
        var keepScroll = (curSel === lastInspSel && prevBody) ? prevBody.scrollTop : null;
        lastInspSel = curSel;

        if (!page) { host.innerHTML = head('No page', 'Select a page to edit') + '<div class="ppd-muted">Nothing to edit yet.</div>'; setType('No selection'); return; }
        if (selId && selId.indexOf('rel:') === 0) {
            if (selPart) renderRelationPart(host, page, selId.slice(4), selPart);
            else renderRelationSection(host, page, selId.slice(4));
        }
        else if (hit && hit.node && selPart) renderPart(host, hit.node, selPart);
        else if (hit && hit.node) renderComponent(host, hit.node);
        else renderPage(host, page);

        if (keepScroll != null) {
            var newBody = qs('.ppd-insp-body', host);
            if (newBody) newBody.scrollTop = keepScroll;
        }
    }

    function head(t, s) { return '<div class="ppd-insp-head"><strong>' + esc(t) + '</strong><small>' + esc(s) + '</small></div>'; }
    function setType(v) { var el = qs('#ppdSelType'); if (el) el.textContent = v; }

    /* ---------- page properties ---------- */
    function renderPage(host, page) {
        setType(title(page.type || 'page') + ' page');
        var L = page.layout || {}; var S = page.settings || {};
        // Page title is two-way synced with Misc → Label Editor → Page Titles
        // (section 'pageTitles', key '<table>.<type>' → build dict page.<table>.<type>),
        // and mirrored onto page.title so the canvas + build fallback stay in step.
        // Common/standalone pages (no table) keep the plain page.title input.
        var ptTbl = String(page.table || store.ui('table') || '');
        var ptCtrl, ptHint = '';
        if (ptTbl === '') {
            ptCtrl = input('text', 'page.title', page.title || '');
        } else {
            var ptKey = ptTbl + '.' + String(page.type || '');
            var ptSaved = store.labelGet('pageTitles', ptKey);
            var ptVal = (ptSaved !== '' && ptSaved != null) ? ptSaved : (page.title || '');
            ptCtrl = '<input type="text" data-label="pageTitles" data-lkey="' + esc(ptKey) + '" data-mirror="@page.title" value="' + esc(ptVal) + '">';
            ptHint = 'Synced with Misc → Label Editor → Page Titles, and translatable per language.';
        }
        // "Page frame" (shell vs blank) only builds for standalone/custom pages —
        // the CRUD + paper page types are always wrapped in the app shell (see
        // BuildWriter generateTableListPage / view / add / edit). Hide the control
        // where it has no effect so the panel stays truthful.
        var SHELL_LOCKED = ['list', 'view', 'add', 'edit', 'print', 'pdf'];
        var frameField = SHELL_LOCKED.indexOf(String(page.type || '')) < 0
            ? field('Page frame', select('page.layout.chrome', [['shell', 'App shell (nav + sidebar)'], ['blank', 'Standalone / Landing (no chrome)']], L.chrome || 'shell'), 'App shell wraps the page in the site navbar, sidebar and footer (best for app/admin pages). Standalone strips all chrome so the page owns the full viewport — use it for landing/marketing pages with their own Navbar & Footer components.')
            : '';
        host.innerHTML =
            head((page.title || 'Page') + ' — page', (page.id || '')) +
            '<div class="ppd-insp-body">' +
                group('General', [
                    field('Page title', ptCtrl, ptHint),
                    field('Page type', input('text', '', page.type || '', true))
                ]) +
                (page.type === 'list' ? tableSettingsGroup(page) : '') +
                (page.type === 'print' || page.type === 'pdf' ? group('Paper', [
                    field('Size', select('page.layout.paper.size', ['A4', 'Letter', 'Legal', 'A5', 'A3'], (L.paper && L.paper.size) || 'A4')),
                    field('Orientation', select('page.layout.paper.orientation', ['portrait', 'landscape'], (L.paper && L.paper.orientation) || 'portrait')),
                    field('Margin', select('page.layout.paper.margin', ['10mm', '15mm', '18mm', '25mm', '30mm'], (L.paper && L.paper.margin) || '18mm'))
                ]) : '') +
                group('Layout', [
                    frameField,
                    field('Width', select('page.layout.width', ['fluid', 'fixed', 'full'], L.width || 'fluid')),
                    field('Alignment', select('page.layout.align', ['left', 'center', 'right'], L.align || 'center')),
                    field('Device', select('page.layout.device', ['desktop', 'tablet', 'mobile'], L.device || 'desktop')),
                    field('Theme', themeSelect('page.layout.theme', L.theme || (PPD.theme && PPD.theme.defaultId()) || 'default')),
                    checkRow([chk('page.layout.dark', 'Dark mode', !!L.dark)]),
                    checkRow([chk('ui.themeDark', 'Preview dark (this session)', store.ui('themeDark'))])
                ]) +
                group('SEO & sharing', [
                    fieldFull('Meta description', textarea('page.settings.description', S.description || ''), 'Shown in search results and link previews. Aim for ~150 characters.'),
                    field('Social image URL', input('text', 'page.settings.ogImage', S.ogImage || '', false, 'https://…/share.jpg'), 'The image shown when the page is shared (Open Graph / Twitter). Absolute URL, ideally 1200×630.'),
                    field('Canonical URL', input('text', 'page.settings.canonical', S.canonical || '', false, 'https://example.com/page'), 'The preferred URL for this page — avoids duplicate-content issues. Leave blank unless the page also lives elsewhere.'),
                    field('Favicon', input('text', 'page.settings.favicon', S.favicon || '', false, 'https://…/favicon.png  or  /custom/favicon.png'), 'The icon in the browser tab. Paste a full URL, or upload one in Custom Files and reference its path (e.g. /custom/favicon.png). Blank = your Style logo. Use a square PNG/SVG/ICO.'),
                    field('Content type', select('page.settings.contentType', [['page', 'Web page'], ['article', 'Article / blog post']], S.contentType || 'page'), 'Article adds blog-post structured data (headline, author, date) for richer search results.'),
                    field('Author', input('text', 'page.settings.author', S.author || '', false, 'Jane Doe'), 'For Article structured data (used when Content type is Article).'),
                    field('Published date', input('text', 'page.settings.published', S.published || '', false, '2026-08-11'), 'ISO date (YYYY-MM-DD) for Article structured data.'),
                    checkRow([chk('page.settings.noindex', 'Hide from search engines', !!S.noindex)])
                ]) +
                group('Advanced', [
                    fieldFull('Head code', textarea('page.settings.headCode', S.headCode || ''), 'Raw HTML injected into <head> — analytics snippets, site-verification tags, custom fonts.'),
                    fieldFull('Custom CSS', textarea('page.settings.customCss', S.customCss || '')),
                    fieldFull('Custom JS', textarea('page.settings.customJs', S.customJs || '')),
                    checkRow([chk('page.settings.locked', 'Lock page', !!S.locked)])
                ]) +
                idBox('pageId: ' + (page.id || '') + '\ntable: ' + (page.table || '—')) +
            '</div>' +
            '<div class="ppd-insp-actions ppd-insp-actions-reset">' +
                // Dashboard (or any page) can be restored to the project's shipped
                // factory design when a snapshot exists — a safety net if a future
                // edit breaks the hand-crafted dashboard.
                ((page.type === 'dashboard' && (window.PpDesignerBootstrap || {}).hasFactory)
                    ? '<button class="ppd-btn danger ppd-btn-xs" data-act="reset-factory" title="Restore this dashboard to its shipped factory design"><i class="bi bi-stars"></i> Reset to factory</button>'
                    : '') +
                '<button class="ppd-btn danger ppd-btn-xs" data-act="reset-page" title="Reset page to default template"><i class="bi bi-arrow-counterclockwise"></i> Reset to default</button>' +
            '</div>';
        bind(host);
        var rb = host.querySelector('[data-act="reset-page"]');
        if (rb) rb.addEventListener('click', function () { resetPageToDefault(page); });
        var fb = host.querySelector('[data-act="reset-factory"]');
        if (fb) fb.addEventListener('click', function () { restoreFactoryDashboard(page); });
    }

    // Restore the dashboard from the project's factory snapshot (surgical — other
    // pages untouched). The Designer autosaves, so a reload safely re-reads the
    // restored design. A pre-reset backup is kept server-side.
    function restoreFactoryDashboard(page) {
        var go = function () {
            if (!PPD.api || !PPD.api.restoreFactoryPage) return;
            PPD.api.restoreFactoryPage(page.table, page.type).then(function (res) {
                if (res && res.success) {
                    if (window.Swal) Swal.fire({ icon: 'success', title: 'Dashboard restored', text: 'Reloading the factory design…', timer: 1400, showConfirmButton: false });
                    setTimeout(function () { window.location.reload(); }, 900);
                } else if (window.Swal) {
                    Swal.fire({ icon: 'error', title: 'Restore failed', text: (res && res.message) || 'Could not restore the dashboard.' });
                }
            });
        };
        if (window.Swal) {
            Swal.fire({
                icon: 'warning', title: 'Reset dashboard to factory?',
                text: 'This replaces the current dashboard design with the shipped factory version. Other pages are untouched, and a backup is kept.',
                showCancelButton: true, confirmButtonText: 'Reset dashboard', confirmButtonColor: '#dc2626'
            }).then(function (r) { if (r.isConfirmed) go(); });
        } else if (window.confirm('Reset the dashboard to factory default?')) { go(); }
    }

    // List-page settings backed by Misc → Table Settings (misc.json byTable).
    // Reads/writes the shared store so the generated list page honours the same
    // records-per-page / sort / toggle config shown in the Misc module.
    function tableSettingsGroup(page) {
        var table = page.table || store.ui('table') || '';
        if (!table) return '';
        var ts = store.tableSettings(table);
        var ls = store.listSettings(table);   // pages.json → settings.list (shared with the Pages module)
        return group('List settings', [
            '<div class="ppd-note"><i class="bi bi-info-circle"></i> Shared with Misc → Table Settings for <b>' + esc(table) + '</b>.</div>',
            field('Records per page', input('number', 'tset.recordsPerPage', ts.recordsPerPage)),
            field('Per-page options', input('text', 'tset.recordsPerPageOptions', ts.recordsPerPageOptions, false, '10,20,50,100')),
            field('Default sort order', select('tset.defaultSortOrder', ['ASC', 'DESC'], ts.defaultSortOrder)),
            checkRow([
                chk('tset.rowHighlight', 'Row highlighting', ts.rowHighlight),
                chk('tset.nextPrevButtons', 'Next / Prev buttons', ts.nextPrevButtons),
                chk('tset.warnLeave', 'Warn before leave', ts.warnLeave),
                chk('tset.loadingBox', 'Loading indicator', ts.loadingBox),
                chk('tset.hideUntilSearch', 'Hide data until search', ts.hideUntilSearch)
            ]),
            '<div class="ppd-subhead">Views, grouping &amp; columns</div>',
            '<div class="ppd-note"><i class="bi bi-columns-gap"></i> Shared with the Pages module. Every setting respects desktop / tablet / mobile.</div>',
            checkRow([
                chk('lset.pinColumns', 'Freeze / pin columns', ls.pinColumns, 'Adds a pin toggle to each column header so users can freeze columns while scrolling. (Pin a column in the grid below to freeze it by default.)'),
                chk('lset.savedViews', 'Saved views', ls.savedViews, 'A toolbar menu where end-users save named presets of search, sort, filters and columns.')
            ]),
            field('Group rows by', fieldSelect('lset.groupBy', ls.groupBy, 'no grouping'), 'Folds the list into collapsible groups with per-group counts and subtotals (subtotals reuse the columns you set a footer aggregate on).'),
            field('Board (Kanban) field', fieldSelect('lset.kanbanField', ls.kanbanField, 'disabled'), 'Adds a Board view that groups records into columns by this field; drag a card between columns to change it.')
        ]);
    }

    // Rebuild the current page from its default template (danger action).
    function resetPageToDefault(page) {
        var type = page.type || page.pageType || store.ui('pageType') || '';
        // Page objects don't always carry .table — fall back to the current
        // table context (same source the rest of the inspector uses).
        var table = page.table || store.ui('table') || '';
        var label = (page.title || type || 'this page');

        // Components the user dropped in from the palette (tagged origin:'user').
        // A "keep my additions" reset restores the default chrome but carries these
        // over. Captured now, before the page is replaced.
        var addedComps = JSON.parse(JSON.stringify((page.components || []).filter(function (c) { return c && c.origin === 'user'; })));

        var doReset = function (keepAdditions) {
            // Table pages (add/edit/list/cards/import/export) → modern
            // client-side template (real fields + toolbar + buttons). Common /
            // auth / dashboard pages → server template.
            var fresh = model.buildDefaultPageFor ? model.buildDefaultPageFor(table, type) : null;
            // A freshly reset page is always editable — never inherit a stale
            // "Lock page" flag from the stored template it was copied from.
            var prep = function (pg) {
                if (pg && typeof pg === 'object') {
                    pg.settings = pg.settings || {}; pg.settings.locked = false;
                    if (keepAdditions && addedComps.length && Array.isArray(pg.components)) {
                        pg.components = pg.components.concat(JSON.parse(JSON.stringify(addedComps)));
                    }
                }
                return pg;
            };
            var finish = function () {
                store.setUi({ selectedId: '', selectedPart: '' });
                if (PPD.panels && PPD.panels.afterContextChange) PPD.panels.afterContextChange();
                var msg = keepAdditions && addedComps.length ? 'Defaults restored — your ' + addedComps.length + ' added component(s) were kept.' : 'Restored the default template design.';
                if (window.Swal) Swal.fire({ icon: 'success', title: 'Page reset', text: msg, timer: 1800, showConfirmButton: false });
                else if (PPD.toast) PPD.toast(msg);
            };
            if (fresh) { model.installTemplatedPage(table, type, prep(fresh)); finish(); return; }
            if (!PPD.api || !PPD.api.template) return;
            PPD.api.template(table, type).then(function (res) {
                if (!res || !res.success || !res.page) throw new Error((res && res.message) || 'Could not load the default template.');
                model.installTemplatedPage(table, type, prep(res.page));
                finish();
            }).catch(function (err) {
                if (window.Swal) Swal.fire({ icon: 'error', title: 'Reset failed', text: err.message || 'Could not reset the page.' });
                else if (PPD.toast) PPD.toast(err.message || 'Reset failed', 'error');
            });
        };

        if (window.Swal) {
            Swal.fire({
                icon: 'warning',
                title: 'Reset this page?',
                html: 'Restore <b>' + esc(label) + '</b> to the default template.' +
                    (addedComps.length ? '<br><span style="font-size:.9em">You added <b>' + addedComps.length + '</b> component(s). Keep them, or do a full reset?</span>' : '<br>All customisations on this page will be lost.'),
                showDenyButton: addedComps.length > 0,
                showCancelButton: true,
                confirmButtonText: 'Full reset',
                denyButtonText: 'Reset, keep my additions',
                cancelButtonText: 'Cancel',
                confirmButtonColor: '#dc2626',
                denyButtonColor: '#2563eb',
                reverseButtons: true,
                focusCancel: true
            }).then(function (r) { if (r.isConfirmed) doReset(false); else if (r.isDenied) doReset(true); });
        } else if (window.confirm('Reset "' + label + '" to the default template design? All customisations will be lost.')) {
            doReset(false);
        }
    }

    /* ---------- component properties ---------- */
    // Every component gets a "Size & layout" (responsive width) group — appended
    // right after General — unless its own schema already defines a spans field.
    // This gives users width control on ALL components, matching the generated app.
    function ensureLayoutGroup(node, schema) {
        // The Cell Grid is a full-bleed page-layout container — it always spans the
        // full width and its responsiveness is per-cell (Desktop/Tablet/Mobile width
        // on each cell), so a component-level Width here would do nothing and only
        // confuse. Skip it. (Mirrors renderer/BuildWriter ignoring its span.)
        if (node && node.type === 'layout-grid') return schema;
        var hasSpans = schema.some(function (g) { return (g.fields || []).some(function (f) { return f.type === 'spans'; }); });
        if (hasSpans) return schema;
        var grp = { title: 'Size & layout', fields: [{ key: 'spans', label: 'Width', type: 'spans' }] };
        var out = schema.slice();
        out.splice(Math.min(1, out.length), 0, grp);
        return out;
    }

    function renderComponent(host, node) {
        var d = reg.get(node.type);
        setType(title(node.type) + ' · ' + (node.label || ''));
        var schema = ensureLayoutGroup(node, d.schema ? d.schema(node) : []);
        var html = head(title(node.label || node.type), node.type + ' · ' + node.id);
        html += '<div class="ppd-insp-body">';
        // A component with a "Labels & text" group already shows the field-label
        // hint there; otherwise show the component-content hint once, on the first
        // group that carries translatable text.
        var hasLabelsGroup = schema.some(function (g) { return g.title === 'Labels & text'; });
        var contentHintShown = false;
        schema.forEach(function (grp) {
            var fields = grp.fields.map(function (f) { return renderField(node, f); });
            // Make it obvious which (primary) language these labels edit.
            if (grp.title === 'Labels & text') {
                fields = [langHintNote()].concat(fields);
            } else if (!hasLabelsGroup && !contentHintShown && grp.fields.some(isI18nTextField)) {
                fields = [contentHintNote()].concat(fields);
                contentHintShown = true;
            }
            html += group(grp.title, fields);
        });
        html += tabAssignGroup(node);
        html += idBox('id: ' + node.id + '\ntype: ' + node.type);
        html += '</div>';
        html += '<div class="ppd-insp-actions"><button class="ppd-btn danger" data-act="del"><i class="bi bi-trash"></i> Delete</button>' +
                '<button class="ppd-btn" data-act="dup"><i class="bi bi-files"></i> Duplicate</button>' +
                '<button class="ppd-btn primary" data-act="save"><i class="bi bi-save"></i> Save</button></div>';
        host.innerHTML = html;
        bind(host, node);
    }

    // When a component lives inside a Tabs container, offer a "Tab" selector to
    // move it between tab panels (drag can't reach a hidden tab). '' when N/A.
    function tabAssignGroup(node) {
        var pg = model.activePage(); if (!pg) return '';
        var h = model.find(pg, node.id); var parent = h && h.parent;
        if (!parent || parent.type !== 'tabs') return '';
        var tabs = (parent.props && parent.props.tabs && parent.props.tabs.length) ? parent.props.tabs : ['Tab 1'];
        var cur = (typeof node.tab === 'number' && node.tab >= 0 && node.tab < tabs.length) ? node.tab : 0;
        var opts = tabs.map(function (t, i) { return '<option value="' + i + '"' + (i === cur ? ' selected' : '') + '>' + esc(t) + '</option>'; }).join('');
        return group('Tab', ['<div class="ppd-field-row"><span>In tab</span><select data-pptab-assign>' + opts + '</select></div>']);
    }

    function renderField(node, f) {
        var target = f.target || 'prop';
        var bag = target === 'style' ? (node.style || {}) : target === 'node' ? node : target === 'visibility' ? (node.visibility || {}) : (node.props || {});
        var val = bag[f.key];
        var path = 'cmp.' + target + '.' + f.key;
        // Conditional field: `when:'key'` shows it once that prop is truthy (e.g.
        // Badge colours after a Badge field is chosen); `when:{key,eq}` shows it
        // only when the prop equals a value (e.g. image-border only for centre=image);
        // `when:{keys:[…],in:[…]}` shows it when ANY of the listed props matches one
        // of the values (e.g. Options only for choice-type inputs or plugins);
        // `when:fn(props)` for anything else.
        if (f.when) {
            var wp = node.props || {};
            if (typeof f.when === 'function') { if (!f.when(wp)) return ''; }
            else if (typeof f.when === 'object') {
                if (Array.isArray(f.when.in)) {
                    var wkeys = f.when.keys || [f.when.key];
                    var hit = wkeys.some(function (k) { return f.when.in.indexOf(String(wp[k] == null ? '' : wp[k])) > -1; });
                    if (!hit) return '';
                } else if (String(wp[f.when.key]) !== String(f.when.eq)) return '';
            }
            else if (!wp[f.when]) return '';
        }
        if (f.type === 'fieldselect') return field(f.label, fieldSelect(path, val, f.placeholder), f.hint);
        if (f.type === 'php') return field(f.label, phpInput(path, val || '', f.key), f.hint);
        if (f.type === 'badgecolors') return badgeColorsEditor(node, f.key);
        if (f.type === 'bool' || f.type === 'check') {
            // `def` lets a bool default to ON when the prop is still unset (so the
            // checkbox matches a render whose default is true, e.g. hero-clock hands).
            var bon = (val == null && f.def != null) ? !!f.def : !!val;
            return checkRow([chk(path, f.label, target === 'visibility' ? val !== false : bon, f.hint)]);
        }
        if (f.type === 'select') return field(f.label, select(path, f.options || [], val == null ? '' : String(val)), f.hint);
        // 'radiogroup' — compact segmented pills for a small single choice (e.g.
        // Progress Card: Manual / Dynamic). Same markup the field-control settings
        // use, so `wireRadioPills` + the radio branch in bind() drive it. Pair with
        // `rerender:true` when the choice gates other rows via `when`.
        if (f.type === 'radiogroup') {
            var rgV = val == null ? '' : String(val);
            var rgHtml = '<div class="ppd-rg is-fit">' + (f.options || []).map(function (o) {
                var x = optVL(o), on = x.v === rgV;
                return '<label class="ppd-rg-opt' + (on ? ' is-on' : '') + '"><input type="radio" name="'
                    + esc(path) + '" value="' + esc(x.v) + '" data-path="' + path + '"' + (on ? ' checked' : '')
                    + '><span>' + esc(x.t) + '</span></label>';
            }).join('') + '</div>';
            return field(f.label, rgHtml, f.hint);
        }
        // 'multiselect' — a set of toggle chips for a MULTI choice over fixed options
        // (e.g. Calendar weekend days). Stores an ARRAY of the selected option values.
        // Wired in bind() via `.ppd-ms-opt` (data-ms-path, NOT data-path, so the
        // generic bind loop doesn't overwrite the array with a single value).
        if (f.type === 'multiselect') {
            var msCur = Array.isArray(val) ? val.map(String) : (val != null && val !== '' ? String(val).split(',').map(function (s) { return s.trim(); }) : []);
            var msHtml = '<div class="ppd-rg is-fit ppd-ms">' + (f.options || []).map(function (o) {
                var x = optVL(o), on = msCur.indexOf(x.v) > -1;
                return '<label class="ppd-rg-opt' + (on ? ' is-on' : '') + '"><input type="checkbox" class="ppd-ms-opt" data-ms-path="' + esc(path) + '" value="' + esc(x.v) + '"' + (on ? ' checked' : '') + '><span>' + esc(x.t) + '</span></label>';
            }).join('') + '</div>';
            return field(f.label, msHtml, f.hint);
        }
        // 'font' — a smart font dropdown everywhere, sourced from the app's bundled
        // font list, so a Font property is never a typo-prone free-text box.
        if (f.type === 'font') return field(f.label, fontSelect(path, val == null ? '' : String(val)), f.hint || 'Bundled web fonts + safe system stacks; each option previews in its font.');
        if (f.type === 'textarea') return fieldFull(f.label, textarea(path, val || ''), f.hint);
        if (f.type === 'csv') return (f.full ? fieldFull : field)(f.label, input('text', path, Array.isArray(val) ? val.join(', ') : (val || ''), false, f.placeholder) + (f.note ? '<small class="ppd-field-note">' + f.note + '</small>' : ''), f.hint);
        if (f.type === 'color') return field(f.label, colorInput(path, val || ''), f.hint);
        if (f.type === 'number') return field(f.label, input('number', path, val == null ? '' : val), f.hint);
        if (f.type === 'range') return field(f.label, rangeInput(path, val, f.min, f.max, f.step, f.suffix, f.def), f.hint);
        if (f.type === 'note') return '<div class="ppd-field-row"><span>' + esc(f.label || '') + '</span></div><div class="ppd-note" style="margin:-2px 0 10px"><i class="bi bi-info-circle"></i> ' + (f.text || '') + '</div>';
        if (f.type === 'gridops') return gridOpsField(node);
        if (f.type === 'icon') return field(f.label, iconPicker(path, val || ''), f.hint || 'Pick from thousands of icons (Bootstrap, FA, Material…).');
        if (f.type === 'list') return listEditor(node, f);
        if (f.type === 'columns') return columnsEditor(node);
        if (f.type === 'conditional') return conditionalEditor(node);
        if (f.type === 'cardrules') return cardConditionalEditor(node);
        if (f.type === 'validations') return validationsEditor(node);
        if (f.type === 'spans') return spansEditor(node, f.key);
        if (f.type === 'control') return controlEditor(node, f);
        if (f.type === 'dbtable') {   // any project table/view (dashboard widgets etc.) + referenceable system tables
            var dbtBoot = window.PpDesignerBootstrap || {};
            var dbtList = dbtBoot.lookupTables || dbtBoot.tables || [];
            var dbtCur = String(val || '');
            var dbtHas = dbtCur === '' || dbtList.some(function (t) { return t.name === dbtCur; });
            var dbtOpts = '<option value="">— choose table —</option>'
                // A saved binding that isn't in the list (an external table, or a view
                // on a driver we can't enumerate) still shows selected — never blank.
                + (dbtHas ? '' : '<option value="' + esc(dbtCur) + '" selected>' + esc(dbtCur) + '</option>')
                + dbtList.map(function (t) {
                    var lbl = t.name + ((t.type === 'VIEW' || t.view) ? ' (view)' : '');
                    return '<option value="' + esc(t.name) + '"' + (t.name === dbtCur ? ' selected' : '') + '>' + esc(lbl) + '</option>';
                }).join('');
            return field(f.label, '<select data-path="' + path + '">' + dbtOpts + '</select>', f.hint);
        }
        if (f.type === 'dbcolumn') {  // a column of the table chosen in another prop
            var dbcBoot = window.PpDesignerBootstrap || {};
            // '__page__' resolves to the table being designed (master key pickers)
            var dbcTbl = f.tableKey === '__page__'
                ? String((model.activePage() || {}).table || store.ui('table') || '')
                : String((node.props || {})[f.tableKey || 'table'] || '');
            // `filter:'text'` keeps only text/blob-capable columns (e.g. a signature
            // needs TEXT/MEDIUMTEXT/LONGTEXT/CHAR/VARCHAR/BLOB/JSON, never int/date).
            var dbcCols = ((dbcBoot.fieldsByTable || {})[dbcTbl] || []);
            if (f.filter === 'text') dbcCols = dbcCols.filter(function (fld) { return /char|text|blob|string|memo|json|clob/i.test(String(fld.type || '')); });
            // Fallback: when a table IS chosen but its columns can't be enumerated
            // (a database VIEW, an external/lookup table, or a filtered-out set),
            // offer a free-text input so the column can still be typed and the saved
            // value stays visible/editable — never a dead "no eligible column" select.
            if (dbcTbl && !dbcCols.length) {
                var dbcHint = (f.hint ? f.hint + ' ' : '') + '(“' + esc(dbcTbl) + '” columns can’t be listed — type the column name.)';
                return field(f.label, '<input type="text" data-path="' + path + '" value="' + esc(String(val || '')) + '" placeholder="column name">', dbcHint);
            }
            var dbcOpts = '<option value="">' + (dbcTbl ? '— choose column —' : 'pick a table first') + '</option>'
                + (dbcCols.map(function (fld) {
                    return '<option' + (fld.name === String(val || '') ? ' selected' : '') + '>' + esc(fld.name) + '</option>';
                }).join(''));
            return field(f.label, '<select data-path="' + path + '">' + dbcOpts + '</select>', f.hint);
        }
        if (f.type === 'dbcolumns') {  // MULTI column picker → chips + an "add" dropdown of the chosen table's columns
            var dbmsBoot = window.PpDesignerBootstrap || {};
            var dbmsTbl = f.tableKey === '__page__'
                ? String((model.activePage() || {}).table || store.ui('table') || '')
                : String((node.props || {})[f.tableKey || 'table'] || '');
            var dbmsAll = ((dbmsBoot.fieldsByTable || {})[dbmsTbl] || []).map(function (fld) { return fld.name; });
            var dbmsSel = Array.isArray(val) ? val.slice() : (val ? String(val).split(',').map(function (s) { return s.trim(); }).filter(Boolean) : []);
            var dbmsAvail = dbmsAll.filter(function (c) { return dbmsSel.indexOf(c) < 0; });
            var dbmsChips = dbmsSel.length
                ? dbmsSel.map(function (c) { return '<span class="ppd-dbcol-chip">' + esc(c) + '<button type="button" class="ppd-dbcol-x" data-dbcol-remove="' + esc(c) + '" data-dbcol-path="' + path + '" aria-label="Remove ' + esc(c) + '">&times;</button></span>'; }).join('')
                : '<span class="ppd-dbcol-empty">All default fields</span>';
            var dbmsAdd = !dbmsTbl
                ? '<small class="ppd-field-note">Pick a table first.</small>'
                : (dbmsAvail.length
                    ? '<select class="ppd-dbcol-add" data-dbcol-path="' + path + '"><option value="">+ add field…</option>' + dbmsAvail.map(function (c) { return '<option value="' + esc(c) + '">' + esc(c) + '</option>'; }).join('') + '</select>'
                    : '<small class="ppd-field-note">All fields added.</small>');
            var dbmsBody = '<div class="ppd-dbcols"><div class="ppd-dbcol-chips">' + dbmsChips + '</div>' + dbmsAdd
                + (f.note ? '<small class="ppd-field-note">' + f.note + '</small>' : '') + '</div>';
            return (f.full ? fieldFull : field)(f.label, dbmsBody, f.hint);
        }
        if (f.type === 'label') return labelField(node, f);
        if (f.type === 'tabs') return tabsEditor(node);
        // Generic text — supports opt-in `full` (stacked, full-width) and an inline
        // `note` helper line (author HTML, e.g. an SQL-variable hint).
        var txtCtrl = input('text', path, val == null ? '' : val, false, f.placeholder)
            + (f.note ? '<small class="ppd-field-note">' + f.note + '</small>' : '');
        return (f.full ? fieldFull : field)(f.label, txtCtrl, f.hint);
    }

    // Each tab entry is a string label (legacy) or { label, icon, bg, color, padding }.
    function normTabs(tabs) {
        return (tabs && tabs.length ? tabs : ['Tab 1']).map(function (t) {
            return (t && typeof t === 'object') ? Object.assign({}, t) : { label: String(t == null ? '' : t) };
        });
    }
    // Per-tab editor: label + icon + independent pane background / text / padding,
    // plus add / remove. Writes through applyPath 'tab.<i>.<key>'.
    function tabsEditor(node) {
        var tabs = normTabs(node.props.tabs);
        var rows = tabs.map(function (t, i) {
            return '<div class="ppd-tabcfg">' +
                '<div class="ppd-tabcfg-h"><strong>Tab ' + (i + 1) + '</strong>' +
                (tabs.length > 1 ? '<button type="button" class="ppd-tabcfg-del" data-tab-del="' + i + '" title="Remove tab"><i class="bi bi-trash"></i></button>' : '') +
                '</div>' +
                field('Label', input('text', 'tab.' + i + '.label', t.label || '')) +
                field('Icon', iconPicker('tab.' + i + '.icon', t.icon || '')) +
                field('Pane background', colorInput('tab.' + i + '.bg', t.bg || '')) +
                field('Pane text', colorInput('tab.' + i + '.color', t.color || '')) +
                field('Pane padding', input('text', 'tab.' + i + '.padding', t.padding || '', false, 'e.g. 16px')) +
                '</div>';
        }).join('');
        return rows + '<button type="button" class="ppd-btn" data-tab-add style="width:100%"><i class="bi bi-plus-lg"></i> Add tab</button>';
    }
    function removeTabAt(node, i) {
        store.mutate(function () {
            var h = model.find(model.activePage(), node.id); if (!h || !h.node) return;
            var arr = normTabs(h.node.props.tabs); arr.splice(i, 1); if (!arr.length) arr = [{ label: 'Tab 1' }];
            h.node.props.tabs = arr;
            (h.node.children || []).forEach(function (ch) {   // keep child→tab assignments valid
                var ct = (typeof ch.tab === 'number') ? ch.tab : 0;
                if (ct === i) ch.tab = Math.max(0, i - 1);
                else if (ct > i) ch.tab = ct - 1;
            });
        });
    }

    // A field value backed by the Misc → Label Editor (placeholders / tooltips),
    // keyed by 'table.field'. Reads/writes the shared label store so the Designer
    // and the Label Editor stay in sync; nothing is stored on the node except an
    // optional WYSIWYG mirror (placeholder → node.props.placeholder).
    function labelField(node, f) {
        var tbl = (model.activePage() || {}).table || store.ui('table') || '';
        var col = (node.props && node.props.field) || '';
        var lkey = tbl + '.' + col;
        var lv = col ? store.labelGet(f.section, lkey) : '';
        // Fallback to the design value when the Label Editor has no override yet:
        //   '@node.label' → the node's own label (title); else a prop name.
        if (lv === '' && f.mirror && col) {
            lv = f.mirror === '@node.label' ? (node.label || '') : ((node.props && node.props[f.mirror]) || '');
        }
        var hint = f.hint ? '<i class="bi bi-question-circle ppd-hint" tabindex="0" data-ppd-tip="' + esc(f.hint) + '"></i>' : '';
        var ctrl = col
            ? '<input type="text" data-label="' + esc(f.section) + '" data-lkey="' + esc(lkey) + '"' + (f.mirror ? ' data-mirror="' + esc(f.mirror) + '"' : '') + ' value="' + esc(lv) + '" placeholder="' + esc(f.ph || '') + '">'
            : '<input type="text" value="" placeholder="set DB field first" disabled>';
        return '<div class="ppd-field-row"><span>' + esc(f.label) + hint + '</span>' + ctrl + '</div>';
    }

    /* A Label-Editor-synced input for a specific table.field, used by the grid
       column/field inspector (where the field is the part key, not node.props).
       `mirror` = '@col.label' also writes the design column label so the canvas
       header updates live. */
    function fieldLabelRow(labelText, section, fld, mirror, designFallback, hint) {
        var tbl = (model.activePage() || {}).table || store.ui('table') || '';
        var lkey = tbl + '.' + fld;
        var lv = store.labelGet(section, lkey);
        if (lv === '' && designFallback) lv = designFallback;
        var h = hint ? '<i class="bi bi-question-circle ppd-hint" tabindex="0" data-ppd-tip="' + esc(hint) + '"></i>' : '';
        var ctrl = '<input type="text" data-label="' + esc(section) + '" data-lkey="' + esc(lkey) + '"'
            + (mirror ? ' data-mirror="' + esc(mirror) + '" data-lfield="' + esc(fld) + '"' : '')
            + ' value="' + esc(lv) + '">';
        return '<div class="ppd-field-row"><span>' + esc(labelText) + h + '</span>' + ctrl + '</div>';
    }
    /* Name of the primary/default language these Label-Editor inputs edit. */
    function defaultLangName() {
        var boot = window.PpDesignerBootstrap || {};
        var code = (store.labelLang && store.labelLang()) || boot.defaultLang || 'en';
        var langs = boot.languages || [];
        for (var i = 0; i < langs.length; i++) { if (langs[i].code === code) return langs[i].name || code; }
        return code === 'en' ? 'English' : code;
    }
    /* A hint chip telling the user which (primary) language they're editing, and
       where to translate the rest. Sits at the top of every "Labels & text" group. */
    function langHintNote() {
        return '<div class="ppd-note ppd-lang-hint"><i class="bi bi-translate"></i> Editing the primary language (<b>'
            + esc(defaultLangName()) + '</b>). Translate other languages in <b>Misc → Label Editor → Field Labels</b>.</div>';
    }
    /* Sibling of langHintNote for COMPONENT text (headings, labels, button text a
       user types on the canvas). These ship translatable via pp_ct(), so the hint
       points at the Translations editor's Content tab rather than Field Labels. */
    function contentHintNote() {
        return '<div class="ppd-note ppd-lang-hint"><i class="bi bi-translate"></i> Text here is the primary language (<b>'
            + esc(defaultLangName()) + '</b>). Translate other languages in <b>Translations → Content</b> (in the built app).</div>';
    }
    /* Does a schema field hold user-facing CONTENT text (vs a config value like a
       WHERE clause, table/column pick, or URL)? Used to decide whether to show the
       translation hint. Heuristic, but only informational — never drives behaviour. */
    function isI18nTextField(f) {
        if (!f || (f.type !== 'text' && f.type !== 'textarea')) return false;
        var k = String(f.key || '');
        // Config-ish text keys that are NOT translatable content.
        if (/^(where|sql|table|column|dateColumn|groupColumn|labelColumn|titleColumn|subtitleColumn|keyColumn|numberColumn|partyAColumn|partyBColumn|typeColumn|repColumn|dateDisplayColumn|emailColumn|phoneColumn|linkTable|url|href|src|id|slabs|rules|template|prefix|suffix|format|icon|class|style|css)$/i.test(k)) return false;
        if (/column$/i.test(k) || /table$/i.test(k)) return false;
        // Content-ish keys: anything ending in Label, a colXxx column header, plus
        // common text props.
        if (/label$/i.test(k)) return true;
        if (/^col[A-Z]/.test(k)) return true;   // colNo, colCase, colParties … (header text)
        return ['heading', 'title', 'subtitle', 'note', 'text', 'message', 'caption', 'legend', 'hint', 'headerText', 'letterhead', 'emptyText'].indexOf(k) > -1
            || /^(heading|title|subtitle)/i.test(k);
    }
    /* The shared "Labels & text" group for a grid field/column (Label + Placeholder
       + Tooltip), all two-way synced with the Misc Label Editor. */
    function gridLabelsGroup(fld, designLabel) {
        return group('Labels & text', [
            langHintNote(),
            fieldLabelRow('Label', 'labels', fld, '@col.label', designLabel || title(fld), 'The field label — shared with Misc → Label Editor → Field Labels'),
            fieldLabelRow('Placeholder', 'placeholders', fld, '', '', 'Shown inside the input on Add/Edit — shared with the Label Editor'),
            fieldLabelRow('Tooltip', 'tooltips', fld, '', '', 'Hover hint — shared with the Label Editor')
        ]);
    }

    // One plugin-setting control. Beyond text/number/select, supports:
    //   'table'  → dropdown of the project's tables
    //   'column' → dropdown of the fields of the table chosen in the sibling
    //              setting named by s.tableKey (re-rendered when it changes)
    //   s.hint   → help tooltip next to the label
    function pluginSettingCtrl(s, v, p, saved) {
        var boot = window.PpDesignerBootstrap || {};
        // An option may be a plain scalar (value == label) OR a [value, label]
        // pair — the File/Image "Store files on" connection list uses pairs.
        var _vt = function (o) { return Array.isArray(o) ? { v: String(o[0]), t: String(o[1] != null ? o[1] : o[0]) } : { v: String(o), t: String(o) }; };
        if (s.type === 'select') {
            // Small, non-empty option sets render as radio pills (nicer + faster
            // to pick than a dropdown); longer lists (colours, fonts) stay selects.
            var _op = s.options || [];
            var _pill = _op.length >= 2 && _op.length <= 4 && _op.every(function (o) { return _vt(o).v !== ''; });
            if (!_pill) return '<select data-path="' + p + '">' + _op.map(function (o) { var x = _vt(o); return '<option value="' + esc(x.v) + '"' + (x.v === String(v) ? ' selected' : '') + '>' + esc(x.t) + '</option>'; }).join('') + '</select>';
        }
        if (s.type === 'number') return '<input type="number" data-path="' + p + '" value="' + esc(v) + '">';
        if (s.type === 'table') {
            var tables = (boot.lookupTables || boot.tables || []).map(function (t) { return t.name; });
            return '<select data-path="' + p + '"><option value="">— choose table —</option>' + tables.map(function (t) {
                return '<option' + (t === String(v) ? ' selected' : '') + '>' + esc(t) + '</option>';
            }).join('') + '</select>';
        }
        if (s.type === 'column') {
            var tbl = String((saved && saved[s.tableKey || 'table']) || '');
            var fields = ((boot.fieldsByTable || {})[tbl] || []).map(function (f) { return f.name; });
            return '<select data-path="' + p + '"><option value="">' + (tbl ? '— choose column —' : 'pick a table first') + '</option>' + fields.map(function (fn) {
                return '<option' + (fn === String(v) ? ' selected' : '') + '>' + esc(fn) + '</option>';
            }).join('') + '</select>';
        }
        if (s.type === 'maincolumn') {   // a column of the ACTIVE table (e.g. cascade parent field)
            var mainTbl = String(((model.activePage() || {}).table) || store.ui('table') || '');
            var mainFields = ((boot.fieldsByTable || {})[mainTbl] || []).map(function (f) { return f.name; });
            return '<select data-path="' + p + '"><option value="">— none —</option>' + mainFields.map(function (fn) {
                return '<option' + (fn === String(v) ? ' selected' : '') + '>' + esc(fn) + '</option>';
            }).join('') + '</select>';
        }
        if (s.type === 'radiogroup' || s.type === 'select') {   // compact segmented pills (single choice)
            return '<div class="ppd-rg">' + (s.options || []).map(function (o) {
                var x = _vt(o), on = x.v === String(v);
                return '<label class="ppd-rg-opt' + (on ? ' is-on' : '') + '"><input type="radio" name="' + esc(p) + '" value="' + esc(x.v) + '" data-path="' + p + '"' + (on ? ' checked' : '') + '><span>' + esc(x.t) + '</span></label>';
            }).join('') + '</div>';
        }
        return '<input type="text" data-path="' + p + '" value="' + esc(v) + '"' + (s.placeholder ? ' placeholder="' + esc(s.placeholder) + '"' : '') + '>';
    }
    function pluginSettingRow(s, v, p, saved) {
        var hint = s.hint ? '<i class="bi bi-question-circle ppd-hint" tabindex="0" data-ppd-tip="' + esc(s.hint) + '"></i>' : '';
        if (s.type === 'bool') {
            // Full-width checkbox row — stores 'yes'/'no' (data-ppbool marker).
            var on = String(v) === 'yes' || v === true || String(v) === '1';
            return '<label class="ppd-field-row ppd-fcm-checkrow"><input type="checkbox" data-path="' + p + '" data-ppbool="1"' + (on ? ' checked' : '') + '><span>' + esc(s.label || s.key) + hint + '</span></label>';
        }
        // `full` stacks the label above a full-width control — for long values
        // (CSV option lists, WHERE clauses) that a label-left row would squeeze.
        // `note` renders an inline helper line under the control (author HTML).
        var scNote = s.note ? '<small class="ppd-field-note">' + s.note + '</small>' : '';
        return '<div class="ppd-field-row' + (s.full ? ' full' : '') + '"><span>' + esc(s.label || s.key) + hint + '</span>' + pluginSettingCtrl(s, v, p, saved) + scNote + '</div>';
    }

    /** Half-width variant (label on top) — two of these pack onto one line. */
    function pluginSettingHalf(s, v, p, saved) {
        var hint = s.hint ? '<i class="bi bi-question-circle ppd-hint" tabindex="0" data-ppd-tip="' + esc(s.hint) + '"></i>' : '';
        if (s.type === 'bool') {
            var on = String(v) === 'yes' || v === true || String(v) === '1';
            return '<label class="ppd-fcm-check-half"><input type="checkbox" data-path="' + p + '" data-ppbool="1"' + (on ? ' checked' : '') + '><span>' + esc(s.label || s.key) + hint + '</span></label>';
        }
        var fit = s.type === 'radiogroup' ? ' is-fit' : '';
        return '<div class="ppd-field-col' + fit + '"><span>' + esc(s.label || s.key) + hint + '</span>' + pluginSettingCtrl(s, v, p, saved) + '</div>';
    }

    /** showIf / showIfSet predicates: hide settings that don't apply right now.
     *  `all` (the full settings list) lets an unsaved driver fall back to its
     *  declared default — otherwise a fresh field would read source="" and hide
     *  the rows that belong to the default mode. */
    function pluginSettingVisible(s, saved, all) {
        if (s.showIf && s.showIf.key) {
            var cur = saved[s.showIf.key];
            if (cur == null || cur === '') {
                var drv = (all || []).filter(function (x) { return x.key === s.showIf.key; })[0];
                cur = drv && drv.default != null ? drv.default : '';
            }
            if ((s.showIf.in || []).map(String).indexOf(String(cur)) < 0) return false;
        }
        if (s.showIfSet) {
            if (String(saved[s.showIfSet] || '') === '') return false;
        }
        return true;
    }

    /** Shared renderer for a field-control's settings: hides inapplicable rows,
     *  packs `inline`/`half` settings into wrapping flow lines, groups under
     *  section headers, and full-width rows keep the label-left grid. Used by
     *  BOTH the config modal and the inline grid-column panel so every input
     *  type (select / text / bool / radiogroup / table / column) renders the
     *  same. `prefix` is the data-path prefix, e.g. 'fcset.editSettings.' or
     *  'part.editSettings.'. */
    function fcSettingsBody(settings, saved, prefix) {
        var groups = [], byGroup = {};
        (settings || []).forEach(function (s) {
            if (!pluginSettingVisible(s, saved, settings)) return;
            var g = s.group || '';
            if (!byGroup[g]) { byGroup[g] = []; groups.push(g); }
            byGroup[g].push(s);
        });
        return groups.map(function (g) {
            var items = byGroup[g], out = '', flow = [];
            var val = function (x) { return saved[x.key] != null ? saved[x.key] : (x.default != null ? x.default : ''); };
            var flush = function () {
                if (flow.length) { out += '<div class="ppd-fcm-flow">' + flow.join('') + '</div>'; flow = []; }
            };
            items.forEach(function (s) {
                if (s.inline || s.half) {
                    if (s.newline) flush();   // start a fresh flow line
                    flow.push(pluginSettingHalf(s, val(s), prefix + s.key, saved));
                } else {
                    flush();
                    out += pluginSettingRow(s, val(s), prefix + s.key, saved);
                }
            });
            flush();
            return (g ? '<div class="ppd-fcm-sec">' + esc(g) + '</div>' : '') + out;
        }).join('');
    }

    // field-control plugin picker: dropdown of View/Edit plugins. A selected
    // plugin's settings open in a dedicated CONFIG MODAL (PHPRunner-style
    // wizard dialog) — roomier than inline rows, shows hints, and dependent
    // dropdowns re-render inside the modal.
    function controlEditor(node, f) {
        var plugins = (window.PpDesignerBootstrap && window.PpDesignerBootstrap.plugins && window.PpDesignerBootstrap.plugins[f.category]) || [];
        var curId = (node.props && node.props[f.key]) || '';
        var opts = '<option value="">— default —</option>' + plugins.map(function (pl) {
            return '<option value="' + esc(pl.id) + '"' + (pl.id === curId ? ' selected' : '') + '>' + esc(pl.label) + '</option>';
        }).join('');
        var out = field(f.label, '<select data-path="cmp.prop.' + f.key + '">' + opts + '</select>');
        var pl = plugins.filter(function (p) { return p.id === curId; })[0];
        if (pl && pl.settings && pl.settings.length) {
            var saved = (node.props && node.props[f.settingsKey]) || {};
            // Compact summary of the configured values + the Configure button.
            var facts = [];
            pl.settings.forEach(function (s) {
                var v = saved[s.key];
                if (v != null && String(v) !== '' && String(v) !== String(s.default != null ? s.default : '')) {
                    facts.push(esc(s.label || s.key) + ': ' + esc(String(v)));
                }
            });
            out += (facts.length ? '<div class="ppd-fc-summary" title="' + facts.join(' · ') + '">' + facts.slice(0, 3).join(' · ') + '</div>' : '')
                + '<button type="button" class="ppd-fc-configure" data-fcm-key="' + esc(f.key) + '" data-fcm-skey="' + esc(f.settingsKey) + '" data-fcm-cat="' + esc(f.category) + '">'
                + '<i class="bi bi-sliders"></i> Configure ' + esc(pl.label) + '…</button>';
        }
        return out;
    }

    /* ---------- plugin configuration modal (PHPRunner-style dialog) ---------- */
    var fcmEl = null, fcmCtx = null;

    function buildFcModal() {
        var el = document.createElement('div');
        el.className = 'ppd-fcm-backdrop';
        el.hidden = true;
        el.innerHTML =
            '<div class="ppd-fcm" role="dialog" aria-modal="true" aria-label="Plugin configuration">' +
                '<header class="ppd-fcm-head"><i class="bi bi-sliders ppd-fcm-ic"></i>' +
                    '<div><strong id="ppdFcmTitle">Configure</strong><small id="ppdFcmSub"></small></div>' +
                    '<button type="button" class="ppd-fcm-close" aria-label="Close">&times;</button></header>' +
                '<div class="ppd-fcm-body" id="ppdFcmBody"></div>' +
                '<div class="ppd-fcm-prev" id="ppdFcmPrev" hidden><div class="ppd-fcm-prev-hd"><i class="bi bi-eye"></i> Live preview' +
                    '<button type="button" class="ppd-fcm-prev-x" id="ppdFcmPrevX" aria-label="Hide preview">&times;</button></div>' +
                    '<div class="ppd-fcm-prev-bd" id="ppdFcmPrevBody"></div></div>' +
                '<footer class="ppd-fcm-foot">' +
                    '<button type="button" class="ppd-fcm-test" id="ppdFcmTest"><i class="bi bi-play-fill"></i> Test</button>' +
                    '<small>Changes apply instantly — the canvas previews live.</small>' +
                    '<button type="button" class="ppd-fcm-done">Done</button></footer>' +
            '</div>';
        var close = function () { el.hidden = true; fcmCtx = null; fcmPreviewOn = false; render(); };   // refresh inspector summary
        el.addEventListener('click', function (e) { if (e.target === el) close(); });
        el.querySelector('.ppd-fcm-close').addEventListener('click', close);
        el.querySelector('.ppd-fcm-done').addEventListener('click', close);
        el.querySelector('#ppdFcmTest').addEventListener('click', function () { fcmPreviewOn = true; runFcPreview(); });
        el.querySelector('#ppdFcmPrevX').addEventListener('click', function () {
            fcmPreviewOn = false; el.querySelector('#ppdFcmPrev').hidden = true;
        });
        document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !el.hidden) close(); });
        document.body.appendChild(el);
        return el;
    }

    /** "Test" — render the control with the CURRENT settings through the real
     *  plugin runtime (live data from the project database). */
    var fcmPreviewOn = false;
    function runFcPreview() {
        if (!fcmCtx || !PPD.api || !PPD.api.fieldPreview) return;
        var node = fcmCtx.node, f = fcmCtx.f;
        var prev = fcmEl.querySelector('#ppdFcmPrev');
        var body = fcmEl.querySelector('#ppdFcmPrevBody');
        prev.hidden = false;
        body.innerHTML = '<span class="ppd-fcm-prev-wait"><i class="bi bi-arrow-repeat"></i> Rendering…</span>';
        PPD.api.fieldPreview({
            category: f.category,
            control: (node.props && node.props[f.key]) || '',
            field: (node.props && node.props.field) || 'field',
            settings: (node.props && node.props[f.settingsKey]) || {},
        }).then(function (res) {
            body.innerHTML = (res && res.success && res.html)
                ? res.html
                : '<span class="ppd-fcm-prev-err">Preview unavailable' + (res && res.message ? ' — ' + esc(res.message) : '') + '</span>';
            // innerHTML-inserted <script> tags don't execute — re-create them so
            // self-enhancing controls (e.g. the chip multi-select) come alive.
            qsa('script', body).forEach(function (old) {
                var sc = document.createElement('script');
                sc.textContent = old.textContent;
                old.parentNode.replaceChild(sc, old);
            });
            if (window.__ppMselInit) window.__ppMselInit();
            if (window.__ppDdmsInit) window.__ppDdmsInit();   // Dropdown chip multi-select
            if (window.__ppNdpInit) window.__ppNdpInit();     // Nepali Date Picker
            wirePreviewAjax(body, node, f);
        }).catch(function () {
            body.innerHTML = '<span class="ppd-fcm-prev-err">Preview failed — check the settings.</span>';
        });
    }

    /** The AJAX lookup box searches live inside the Test preview too. */
    function wirePreviewAjax(body, node, f) {
        var box = body.querySelector('[data-pp-lkajax]');
        if (!box || !PPD.api || !PPD.api.lookupOptions) return;
        var panel = document.createElement('div');
        panel.className = 'ppd-fcm-sug';
        panel.hidden = true;
        box.insertAdjacentElement('afterend', panel);
        var t = null;
        box.addEventListener('input', function () {
            clearTimeout(t);
            var q = box.value.trim();
            if (q === '') { panel.hidden = true; return; }
            t = setTimeout(function () {
                PPD.api.lookupOptions({ settings: (node.props && node.props[f.settingsKey]) || {}, q: q }).then(function (res) {
                    var opts = (res && res.options) || {};
                    var keys = Object.keys(opts);
                    if (!keys.length) { panel.hidden = true; return; }
                    panel.innerHTML = keys.map(function (k) {
                        return '<div class="ppd-fcm-sug-it" data-k="' + esc(k) + '">' + esc(opts[k]) + '</div>';
                    }).join('');
                    panel.hidden = false;
                    qsa('.ppd-fcm-sug-it', panel).forEach(function (it) {
                        it.addEventListener('mousedown', function (ev) {
                            ev.preventDefault();
                            box.value = it.textContent;
                            panel.hidden = true;
                        });
                    });
                }).catch(function () { panel.hidden = true; });
            }, 200);
        });
        box.addEventListener('blur', function () { setTimeout(function () { panel.hidden = true; }, 150); });
    }

    function renderFcModalBody() {
        if (!fcmCtx) return;
        var node = fcmCtx.node, f = fcmCtx.f;
        var plugins = (window.PpDesignerBootstrap && window.PpDesignerBootstrap.plugins && window.PpDesignerBootstrap.plugins[f.category]) || [];
        var curId = (node.props && node.props[f.key]) || '';
        var pl = plugins.filter(function (p) { return p.id === curId; })[0];
        if (!pl) { fcmEl.hidden = true; return; }
        fcmEl.querySelector('#ppdFcmTitle').textContent = pl.label;
        fcmEl.querySelector('#ppdFcmSub').textContent = (node.props.label || node.props.field || node.label || '') + ' · ' + f.category + ' control';
        var saved = (node.props && node.props[f.settingsKey]) || {};
        var body = fcmEl.querySelector('#ppdFcmBody');
        // Groups + half-width flow packing, shared with the inline grid panel.
        body.innerHTML = fcSettingsBody(pl.settings, saved, 'fcset.' + f.settingsKey + '.');
        // Bind: writes go through the normal applyPath pipeline (auto-clear of
        // stale dependent columns + "Apply to all pages" propagation included).
        // Choice inputs re-render the MODAL so dependent pickers cascade and
        // conditional rows appear/disappear; the open preview re-renders too.
        qsa('[data-path]', body).forEach(function (el) {
            var isChoice = el.type === 'checkbox' || el.type === 'radio' || el.tagName === 'SELECT' || el.type === 'color';
            el.addEventListener(isChoice ? 'change' : 'input', function () {
                var val = el.type === 'checkbox'
                    ? (el.getAttribute('data-ppbool') ? (el.checked ? 'yes' : 'no') : el.checked)
                    : el.value;
                applyPath(el.getAttribute('data-path'), val, node);
                if (isChoice && el.type !== 'color') renderFcModalBody();
                if (fcmPreviewOn) runFcPreview();
            });
        });
        wireRadioPills(body);
    }

    /* Segmented radio pills: force-select the radio on label click. Belt-and-
       suspenders for browsers (Safari) where a click on a label-wrapped control
       doesn't reliably toggle it — guarantees the change event fires. */
    function wireRadioPills(scope) {
        qsa('.ppd-rg-opt', scope).forEach(function (lab) {
            lab.addEventListener('click', function () {
                var r = lab.querySelector('input[type="radio"]');
                if (r && !r.checked) { r.checked = true; r.dispatchEvent(new Event('change', { bubbles: true })); }
            });
        });
    }

    function openPluginModal(node, f) {
        fcmCtx = { node: node, f: f };
        if (!fcmEl) fcmEl = buildFcModal();
        renderFcModalBody();
        fcmEl.hidden = false;
    }

    /* ---------- field-level Edit / View / Filter control studio (double-click a field) ----------
       Assign a field's Edit, View AND Filter controls from ONE place. Saved to the
       field-level store (fields.byTable[t].editAs/viewAs/filterAs) which the build
       applies to EVERY page (add, edit, list, view, print, export, filter) — no
       per-page repetition. A PHPRunner-style left control list + right settings, plus
       a live preview and searchable list (better than the usual RAD). */
    var fcxEl = null, fcx = null, fcxPrevT = null;

    var FCX_ICONS = {
        PlainText: 'bi-fonts', Text: 'bi-fonts', ShortDate: 'bi-calendar3', LongDate: 'bi-calendar3',
        Datetime: 'bi-calendar-week', Time: 'bi-clock', Currency: 'bi-currency-dollar', Percent: 'bi-percent',
        Hyperlink: 'bi-link-45deg', EmailHyperlink: 'bi-envelope', Image: 'bi-image', File: 'bi-file-earmark',
        PhoneNumber: 'bi-telephone', Number: 'bi-123', HTML: 'bi-code-slash', Checkbox: 'bi-check-square',
        Map: 'bi-geo-alt', Custom: 'bi-gear', Audio: 'bi-music-note-beamed', Video: 'bi-film', QRCode: 'bi-qr-code',
        TextBox: 'bi-input-cursor-text', TextArea: 'bi-textarea-resize', Password: 'bi-key', DatePicker: 'bi-calendar3',
        TimePicker: 'bi-clock', FileImage: 'bi-cloud-arrow-up', LookupWizard: 'bi-search', Dropdown: 'bi-menu-button-wide',
        RadioGroup: 'bi-ui-radios', ButtonToggle: 'bi-toggles', Switch: 'bi-toggle-on', StarRating: 'bi-star',
        NepaliDatePicker: 'bi-calendar-event', MarkdownEditor: 'bi-markdown', ReadOnlyControl: 'bi-eye-slash',
        ColorPicker: 'bi-palette', SignaturePad: 'bi-pen', TextFilter: 'bi-fonts', SelectFilter: 'bi-menu-button-wide',
        DropdownFilter: 'bi-menu-button-wide', NumberRange: 'bi-arrows-collapse-vertical', DateRange: 'bi-calendar-range'
    };
    function fcxIcon(id) { return FCX_ICONS[id] || 'bi-puzzle'; }
    var FCX_CAT_TABS = [
        { key: 'edit', cat: 'edit', label: 'Edit', ic: 'bi-pencil-square', where: 'Add · Edit forms' },
        { key: 'view', cat: 'view', label: 'View', ic: 'bi-eye', where: 'List · View · Print' },
        { key: 'filter', cat: 'filter', label: 'Filter', ic: 'bi-funnel', where: 'List filter panel' },
        { key: 'validation', cat: 'validation', label: 'Validation', ic: 'bi-shield-check', where: 'Checked on save' }
    ];
    var FCX_PAGE_TABS = [
        { key: 'list', cat: 'view', label: 'List', ic: 'bi-table' },
        { key: 'add', cat: 'edit', label: 'Add', ic: 'bi-plus-square' },
        { key: 'edit', cat: 'edit', label: 'Edit', ic: 'bi-pencil-square' },
        { key: 'view', cat: 'view', label: 'View', ic: 'bi-eye' },
        { key: 'search', cat: 'filter', label: 'Search', ic: 'bi-funnel' },
        // Validation is page-agnostic (it guards the saved value), so it stays an
        // all-pages tab even in per-page mode — same shared fcx.validation stack.
        { key: 'validation', cat: 'validation', label: 'Validation', ic: 'bi-shield-check', where: 'Checked on save' }
    ];
    var FCX_AUTO = {
        edit: 'Uses a smart input based on the column type (text, date, number…).',
        view: 'Shows the plain stored value.',
        filter: 'A plain contains-text filter.'
    };
    var FCX_CAT_LABEL = { edit: 'Edit', view: 'View', filter: 'Filter' };

    function fcxPlugins(cat) {
        return (window.PpDesignerBootstrap && window.PpDesignerBootstrap.plugins && window.PpDesignerBootstrap.plugins[cat]) || [];
    }
    function fcxTabDefs() { return fcx && fcx.perPage ? FCX_PAGE_TABS : FCX_CAT_TABS; }
    function fcxActiveTab() {
        var ts = fcxTabDefs();
        for (var i = 0; i < ts.length; i++) { if (ts[i].key === fcx.active) return ts[i]; }
        return ts[0];
    }
    function fcxActiveCat() { return fcxActiveTab().cat; }
    // The draft being edited: a category draft in default mode, or a per-page
    // override (fcx.pages[key]) in per-page mode. Empty per-page type = inherit.
    function fcxActiveDraft() {
        var t = fcxActiveTab();
        if (!fcx.perPage) return fcx[t.key];
        if (!fcx.pages[t.key]) fcx.pages[t.key] = { type: '', settings: {} };
        return fcx.pages[t.key];
    }

    function buildFcxModal() {
        var el = document.createElement('div');
        el.className = 'ppd-fcm-backdrop ppd-fcx-backdrop';
        el.hidden = true;
        el.innerHTML =
            '<div class="ppd-fcm ppd-fcx" role="dialog" aria-modal="true" aria-label="Field controls">' +
                '<header class="ppd-fcm-head"><span class="ppd-fcm-ic"><i class="bi bi-ui-checks-grid"></i></span>' +
                    '<div><strong id="ppdFcxTitle">Field controls</strong><small id="ppdFcxSub"></small></div>' +
                    '<label class="ppd-fcx-perpage" title="Give each page its own control (list, add, edit, view, search)"><input type="checkbox" id="ppdFcxPerPage"> Different per page</label>' +
                    '<button type="button" class="ppd-fcx-reset" title="Reset this tab"><i class="bi bi-arrow-counterclockwise"></i> Reset</button>' +
                    '<button type="button" class="ppd-fcm-close" aria-label="Close">&times;</button></header>' +
                '<div class="ppd-fcx-tabs" id="ppdFcxTabs"></div>' +
                '<div class="ppd-fcx-main">' +
                    '<div class="ppd-fcx-list-col"><div class="ppd-fcx-search"><i class="bi bi-search"></i>' +
                        '<input type="text" id="ppdFcxSearch" placeholder="Search controls…" autocomplete="off"></div>' +
                        '<div class="ppd-fcx-list" id="ppdFcxList"></div></div>' +
                    '<div class="ppd-fcx-detail" id="ppdFcxDetail"></div>' +
                '</div>' +
                '<footer class="ppd-fcm-foot"><small id="ppdFcxFoot"><i class="bi bi-magic"></i> Assign once — applied to every page automatically.</small>' +
                    '<button type="button" class="ppd-fcm-done ppd-fcx-save">Done</button></footer>' +
            '</div>';
        document.body.appendChild(el);
        el.querySelector('.ppd-fcm-close').addEventListener('click', closeFcx);
        el.querySelector('.ppd-fcx-save').addEventListener('click', saveFcx);
        el.querySelector('.ppd-fcx-reset').addEventListener('click', function () {
            if (!fcx) return;
            if (fcxActiveCat() === 'validation') { fcx.validation = []; }
            else if (fcx.perPage) { fcx.pages[fcx.active] = { type: '', settings: {} }; }
            else { fcx[fcx.active] = { type: '', settings: {} }; }
            renderFcxList(); renderFcxDetail(); syncFcxTabs();
        });
        el.querySelector('#ppdFcxPerPage').addEventListener('change', function () {
            fcx.perPage = this.checked; fcx.active = fcxTabDefs()[0].key; fcx.q = '';
            renderFcxTabs(); renderFcxList(); renderFcxDetail(); updateFcxFoot();
        });
        el.addEventListener('mousedown', function (e) { if (e.target === el) closeFcx(); });
        el.querySelector('#ppdFcxSearch').addEventListener('input', function () { fcx.q = this.value; renderFcxList(); });
        document.addEventListener('keydown', function (e) { if (fcxEl && !fcxEl.hidden && e.key === 'Escape') closeFcx(); });
        return el;
    }

    function renderFcxTabs() {
        var host = fcxEl.querySelector('#ppdFcxTabs');
        host.innerHTML = fcxTabDefs().map(function (t) {
            return '<button type="button" class="ppd-fcx-tab" data-key="' + t.key + '"><i class="bi ' + t.ic + '"></i> ' + t.label +
                (t.where ? ' <small>' + t.where + '</small>' : '') + '</button>';
        }).join('');
        qsa('.ppd-fcx-tab', host).forEach(function (b) {
            b.addEventListener('click', function () { fcx.active = b.getAttribute('data-key'); fcx.q = ''; syncFcxTabs(); renderFcxList(); renderFcxDetail(); });
        });
        syncFcxTabs();
    }

    function updateFcxFoot() {
        var f = fcxEl.querySelector('#ppdFcxFoot');
        f.innerHTML = fcx.perPage
            ? '<i class="bi bi-layers"></i> Each page uses its own control; blank = inherit the all-pages default.'
            : '<i class="bi bi-magic"></i> Assign once — applied to every page automatically.';
    }

    function syncFcxTabs() {
        qsa('.ppd-fcx-tab', fcxEl).forEach(function (t) {
            var key = t.getAttribute('data-key');
            t.classList.toggle('is-on', key === fcx.active);
            var set = key === 'validation'
                ? ((fcx.validation || []).length > 0)
                : (fcx.perPage ? (fcx.pages[key] && fcx.pages[key].type) : (fcx[key] && fcx[key].type));
            var badge = t.querySelector('.ppd-fcx-tab-dot') || (function () { var b = document.createElement('span'); b.className = 'ppd-fcx-tab-dot'; t.appendChild(b); return b; })();
            badge.style.display = set ? '' : 'none';
        });
        var s = fcxEl.querySelector('#ppdFcxSearch'); if (s) s.value = fcx.q || '';
    }

    function renderFcxList() {
        if (fcxActiveCat() === 'validation') return renderFcxValList();
        var cat = fcxActiveCat(), draft = fcxActiveDraft(), q = (fcx.q || '').toLowerCase();
        var autoLabel = fcx.perPage ? 'Inherit (all-pages default)' : 'Auto (default)';
        var items = [{ id: '', label: autoLabel, auto: true }].concat(fcxPlugins(cat));
        var html = items.filter(function (p) {
            return !q || (p.label || '').toLowerCase().indexOf(q) > -1 || (p.id || '').toLowerCase().indexOf(q) > -1;
        }).map(function (p) {
            var on = (p.id || '') === (draft.type || '');
            var ic = p.auto ? (fcx.perPage ? 'bi-arrow-down-left-circle' : 'bi-magic') : fcxIcon(p.id);
            return '<button type="button" class="ppd-fcx-item' + (on ? ' is-on' : '') + (p.auto ? ' is-auto' : '') + '" data-id="' + esc(p.id) + '">' +
                '<i class="bi ' + ic + '"></i><span>' + esc(p.label) + '</span>' + (on ? '<i class="bi bi-check2 ppd-fcx-item-ck"></i>' : '') + '</button>';
        }).join('');
        var listEl = fcxEl.querySelector('#ppdFcxList');
        listEl.innerHTML = html || '<div class="ppd-fcx-nomatch">No controls match “' + esc(fcx.q) + '”.</div>';
        qsa('.ppd-fcx-item', listEl).forEach(function (b) {
            b.addEventListener('click', function () {
                var id = b.getAttribute('data-id');
                if ((draft.type || '') !== id) { draft.type = id; draft.settings = {}; }
                renderFcxList(); renderFcxDetail(); syncFcxTabs();
            });
        });
    }

    // ---- Validation tab: a click-to-add rule palette (left) + a stack of the
    // added rules with their params + custom message (right). Rules are stored
    // at field level (validationAs[field]) and enforced client + server on save.
    // Per-rule plain-language help + a distinct icon so the palette and cards read
    // clearly (keyed by the rule id from PluginRegistry::validation()).
    var VRULE_META = {
        required:     { ic: 'bi-asterisk',         desc: 'The field cannot be left empty.' },
        email:        { ic: 'bi-envelope-at',      desc: 'Must be a valid email address, like name@example.com.' },
        phone:        { ic: 'bi-telephone',        desc: 'Must look like a phone number; set the allowed digit count below.' },
        fullName:     { ic: 'bi-person-vcard',     desc: 'Must contain at least the given number of words (e.g. first + last name).' },
        number:       { ic: 'bi-123',              desc: 'Must be a number — optionally whole-number only and within a min/max range.' },
        length:       { ic: 'bi-rulers',           desc: 'Limits how many characters the value may contain.' },
        regex:        { ic: 'bi-code-slash',       desc: 'Advanced: the value must match a regular-expression pattern you provide.' },
        match:        { ic: 'bi-check2-square',    desc: 'Must exactly match another field on the form (e.g. confirm password).' },
        url:          { ic: 'bi-link-45deg',       desc: 'Must be a valid web address, like https://example.com.' },
        alpha:        { ic: 'bi-alphabet',         desc: 'Letters only — optionally allow spaces.' },
        alphanumeric: { ic: 'bi-fonts',            desc: 'Letters and numbers only — optionally allow spaces.' },
        ip:           { ic: 'bi-hdd-network',      desc: 'Must be a valid IP address (IPv4 or IPv6).' },
        creditCard:   { ic: 'bi-credit-card',      desc: 'Must be a valid card number (passes the Luhn checksum).' },
        date:         { ic: 'bi-calendar-check',   desc: 'Must be a valid date — optionally bound the range, block past/future, or require a minimum age.' },
        time:         { ic: 'bi-clock',            desc: 'Must be a valid time — optionally within a min/max range.' },
        password:     { ic: 'bi-shield-lock',      desc: 'Enforces password strength: length plus upper/lower/digit/symbol.' },
        mustAccept:   { ic: 'bi-check-circle',     desc: 'The checkbox must be ticked (e.g. accept the terms).' },
        inList:       { ic: 'bi-list-check',       desc: 'The value must be one of an allowed list you provide.' },
        unique:       { ic: 'bi-fingerprint',      desc: 'No other record may already have this value (checked on the server).' },
        json:         { ic: 'bi-braces',           desc: 'The value must be well-formed JSON.' },
        compare:      { ic: 'bi-arrow-left-right', desc: 'Compare this field to another — e.g. end date after start date.' },
        different:    { ic: 'bi-shuffle',          desc: 'Must be different from another field on the form.' }
    };
    function vRuleIc(id) { return (VRULE_META[id] && VRULE_META[id].ic) || 'bi-shield-check'; }
    function vRuleDesc(id) { return (VRULE_META[id] && VRULE_META[id].desc) || ''; }

    // Ready-made regex patterns the author can drop in with one click.
    var VRX_EXAMPLES = [
        { label: 'Letters only',      rx: '^[A-Za-z]+$' },
        { label: 'Digits only',       rx: '^\\d+$' },
        { label: 'Letters & numbers', rx: '^[A-Za-z0-9]+$' },
        { label: 'Username',          rx: '^[A-Za-z0-9_]{3,20}$' },
        { label: 'Slug',              rx: '^[a-z0-9]+(?:-[a-z0-9]+)*$' },
        { label: 'US ZIP',            rx: '^\\d{5}(-\\d{4})?$' },
        { label: 'Hex colour',        rx: '^#?[0-9A-Fa-f]{6}$', f: 'i' }
    ];
    // Validators that get a full "Live tester" modal (regex has its own, richer one).
    var VTESTABLE = { regex: 1, inList: 1, length: 1, compare: 1 };
    var VHELP = {
        inList: 'The value must equal one of the allowed values. Toggle <b>Ignore case</b> to accept any casing.',
        length: 'The value\'s character count must be within Min…Max. Leave one blank for no bound.',
        compare: 'The value is compared to another field on the same record. <b>number</b> parses digits, <b>date</b> parses dates, <b>text</b> compares as strings.'
    };

    function renderFcxValList() {
        var cat = fcxPlugins('validation'), q = (fcx.q || '').toLowerCase();
        var stack = fcx.validation || [];
        var counts = {};
        stack.forEach(function (r) { counts[r.type] = (counts[r.type] || 0) + 1; });
        var groups = {}, order = [];
        cat.forEach(function (d) {
            if (q && (d.label || '').toLowerCase().indexOf(q) < 0 && (d.id || '').toLowerCase().indexOf(q) < 0) return;
            var g = d.group || 'General'; if (!groups[g]) { groups[g] = []; order.push(g); } groups[g].push(d);
        });
        var html = order.map(function (g) {
            return '<div class="ppd-fcx-vgroup">' + esc(g) + '</div>' + groups[g].map(function (d) {
                var n = counts[d.id] || 0;
                return '<button type="button" class="ppd-fcx-item ppd-fcx-vadd" data-vadd="' + esc(d.id) + '" title="' + esc(vRuleDesc(d.id) || 'Add this rule') + '">' +
                    '<i class="bi ' + vRuleIc(d.id) + '"></i><span>' + esc(d.label) + '</span>' +
                    (n ? '<span class="ppd-fcx-vcount">' + n + '</span>' : '<i class="bi bi-plus-lg ppd-fcx-item-ck"></i>') + '</button>';
            }).join('');
        }).join('');
        var listEl = fcxEl.querySelector('#ppdFcxList');
        listEl.innerHTML = html || '<div class="ppd-fcx-nomatch">No rules match “' + esc(fcx.q) + '”.</div>';
        qsa('.ppd-fcx-vadd', listEl).forEach(function (b) {
            b.addEventListener('click', function () {
                var id = b.getAttribute('data-vadd');
                fcx.validation = (fcx.validation || []).slice();
                fcx.validation.push({ type: id, params: {}, message: '' });
                renderFcxValList(); renderFcxValDetail(); syncFcxTabs();
            });
        });
    }

    function fcxVParamCtrl(i, ps, val) {
        var key = 'params.' + ps.key;
        var hint = ps.hint ? '<small class="ppd-vr-hint">' + esc(ps.hint) + '</small>' : '';
        if (ps.type === 'bool') {
            return '<label class="ppd-vr-check"><input type="checkbox" data-vr="' + i + '" data-vrkey="' + key + '" ' + (vFlag(val) ? 'checked' : '') + '> <span>' + esc(ps.label) + '</span></label>';
        }
        if (ps.type === 'select') {
            var opts = (ps.options || []).map(function (o) { return '<option' + (String(o) === String(val) ? ' selected' : '') + '>' + esc(o) + '</option>'; }).join('');
            return '<label class="ppd-vr-field"><span>' + esc(ps.label) + '</span><select data-vr="' + i + '" data-vrkey="' + key + '">' + opts + '</select>' + hint + '</label>';
        }
        var t = ps.type === 'number' ? 'number' : 'text';
        return '<label class="ppd-vr-field"><span>' + esc(ps.label) + '</span><input type="' + t + '" data-vr="' + i + '" data-vrkey="' + key + '" value="' + esc(val == null ? '' : val) + '"' + (ps.placeholder ? ' placeholder="' + esc(ps.placeholder) + '"' : '') + '>' + hint + '</label>';
    }

    // Rich helper shown inside the Regular-expression rule card: what it does,
    // one-click example patterns, and a live tester so the author sees the pattern
    // match or fail before saving.
    function fcxRegexHelper(i) {
        var chips = VRX_EXAMPLES.map(function (e) {
            return '<button type="button" class="ppd-vr-chip" data-rxi="' + i + '" data-rx="' + esc(e.rx) + '" data-rxf="' + esc(e.f || '') + '">' + esc(e.label) + '</button>';
        }).join('');
        return '<div class="ppd-vr-rx">' +
            '<div class="ppd-vr-rx-intro"><i class="bi bi-info-circle"></i> Enter a pattern the value must match — no slashes needed. Add flags like <code>i</code> (ignore case). It runs live in the browser and again on the server.</div>' +
            '<div class="ppd-vr-rx-ex"><span class="ppd-vr-rx-exlabel">Examples</span>' + chips + '</div>' +
            '<div class="ppd-vr-rx-test"><span class="ppd-vr-rx-testlbl">Try it</span>' +
                '<input type="text" class="ppd-vr-rx-testin" data-rxi="' + i + '" placeholder="Type a value to test…">' +
                '<span class="ppd-vr-rx-result" data-rxres="' + i + '"></span>' +
                '<button type="button" class="ppd-vr-rx-open" data-rxopen="' + i + '" title="Open a full tester: multiple values, flags, capture groups"><i class="bi bi-window-fullscreen"></i> Live tester</button></div>' +
            '<div class="ppd-vr-rx-cheat"><code>\\d</code> digit · <code>\\w</code> letter/number · <code>.</code> any · <code>+</code> 1+ · <code>*</code> 0+ · <code>{2,4}</code> count · <code>^ $</code> start/end · <code>[A-Z]</code> a set · <code>a|b</code> or</div>' +
            '</div>';
    }

    function fcxRegexResult(i) {
        var box = fcxEl.querySelector('[data-rxres="' + i + '"]');
        var input = fcxEl.querySelector('.ppd-vr-rx-testin[data-rxi="' + i + '"]');
        if (!box || !input) return;
        var r = (fcx.validation || [])[i]; if (!r) return;
        var pat = (r.params && r.params.pattern) || '';
        var val = input.value;
        if (!pat) { box.className = 'ppd-vr-rx-result'; box.textContent = 'Add a pattern first'; return; }
        if (val === '') { box.className = 'ppd-vr-rx-result'; box.textContent = ''; return; }
        var flags = ((r.params && r.params.flags) || '').replace(/[^imsuy]/g, '');
        try {
            var ok = new RegExp(pat, flags).test(val);
            box.className = 'ppd-vr-rx-result ' + (ok ? 'is-ok' : 'is-no');
            box.innerHTML = ok ? '<i class="bi bi-check-circle-fill"></i> Matches' : '<i class="bi bi-x-circle-fill"></i> No match';
        } catch (e) {
            box.className = 'ppd-vr-rx-result is-err';
            box.innerHTML = '<i class="bi bi-exclamation-triangle-fill"></i> Invalid pattern';
        }
    }

    // Full-screen "Live tester" modal for a regex rule: edit the pattern + flags,
    // check many values at once (one per line), see capture groups, and apply the
    // pattern back to the rule — so the author perfects it before saving.
    function openRegexTester(i) {
        var r = (fcx.validation || [])[i]; if (!r) return;
        r.params = r.params || {};
        var exChips = VRX_EXAMPLES.map(function (e) {
            return '<button type="button" class="ppd-vr-chip" data-exrx="' + esc(e.rx) + '" data-exf="' + esc(e.f || '') + '">' + esc(e.label) + '</button>';
        }).join('');
        var wrap = document.createElement('div');
        wrap.className = 'ppd-modal ppd-rxtest';
        wrap.innerHTML =
            '<div class="ppd-modal-box ppd-rxtest-box" role="dialog" aria-label="Regular expression tester">' +
              '<div class="ppd-modal-head"><span><i class="bi bi-braces-asterisk"></i> Regular expression — live test</span>' +
                '<button type="button" class="ppd-btn" data-cancel><i class="bi bi-x-lg"></i></button></div>' +
              '<div class="ppd-rxtest-body">' +
                '<div class="ppd-rxtest-patrow">' +
                  '<label class="ppd-rxtest-pat"><span>Pattern</span><input type="text" data-rxpat spellcheck="false" placeholder="^[A-Za-z0-9_]{3,20}$"></label>' +
                  '<label class="ppd-rxtest-flags"><span>Flags</span><input type="text" data-rxflags spellcheck="false" maxlength="6" placeholder="i"></label>' +
                '</div>' +
                '<div class="ppd-rxtest-status" data-rxstatus></div>' +
                '<div class="ppd-rxtest-ex"><span class="ppd-vr-rx-exlabel">Examples</span>' + exChips + '</div>' +
                '<label class="ppd-rxtest-lbl">Test values <small>— one per line; each is checked exactly as the field would be</small></label>' +
                '<div class="ppd-rxtest-grid">' +
                  '<textarea data-rxsamples spellcheck="false" placeholder="Type values to test, one per line…"></textarea>' +
                  '<div class="ppd-rxtest-results" data-rxresults aria-hidden="true"></div>' +
                '</div>' +
                '<div class="ppd-rxtest-summary" data-rxsummary></div>' +
                '<div class="ppd-rxtest-groups" data-rxgroups></div>' +
                '<div class="ppd-vr-rx-cheat"><code>\\d</code> digit · <code>\\w</code> letter/number · <code>.</code> any · <code>+</code> 1+ · <code>*</code> 0+ · <code>{2,4}</code> count · <code>^ $</code> start/end · <code>[A-Z]</code> a set · <code>a|b</code> or · <code>(…)</code> capture group</div>' +
              '</div>' +
              '<div class="ppd-modal-foot"><button type="button" class="ppd-btn" data-cancel>Cancel</button>' +
                '<button type="button" class="ppd-btn primary" data-apply><i class="bi bi-check-lg"></i> Use this pattern</button></div>' +
            '</div>';
        document.body.appendChild(wrap);
        var patEl = wrap.querySelector('[data-rxpat]'), flagsEl = wrap.querySelector('[data-rxflags]'),
            samplesEl = wrap.querySelector('[data-rxsamples]'), resultsEl = wrap.querySelector('[data-rxresults]'),
            statusEl = wrap.querySelector('[data-rxstatus]'), groupsEl = wrap.querySelector('[data-rxgroups]'),
            summaryEl = wrap.querySelector('[data-rxsummary]');
        patEl.value = r.params.pattern || '';
        flagsEl.value = (r.params.flags || '').replace(/[^gimsuy]/g, '');
        function run() {
            var pat = patEl.value, flags = (flagsEl.value || '').replace(/[^gimsuy]/g, ''), re = null;
            if (pat === '') { statusEl.className = 'ppd-rxtest-status'; statusEl.innerHTML = '<i class="bi bi-info-circle"></i> Enter a pattern above.'; }
            else {
                try { re = new RegExp(pat, flags.replace(/g/g, '')); statusEl.className = 'ppd-rxtest-status is-ok'; statusEl.innerHTML = '<i class="bi bi-check2-circle"></i> Valid pattern' + (flags ? ' — flags <code>' + esc(flags) + '</code>' : ''); }
                catch (e) { statusEl.className = 'ppd-rxtest-status is-err'; statusEl.innerHTML = '<i class="bi bi-exclamation-triangle-fill"></i> ' + esc(e.message); }
            }
            var lines = samplesEl.value.split('\n'), out = '', pass = 0, total = 0, firstGroups = null;
            lines.forEach(function (v) {
                if (v === '') { out += '<div class="ppd-rxtest-r is-blank">&nbsp;</div>'; return; }
                total++;
                var ok = false, m = null;
                if (re) { try { ok = re.test(v); m = re.exec(v); } catch (e) {} }
                if (ok) pass++;
                out += '<div class="ppd-rxtest-r ' + (re ? (ok ? 'is-ok' : 'is-no') : '') + '">' + (re ? (ok ? '<i class="bi bi-check-circle-fill"></i>' : '<i class="bi bi-x-circle-fill"></i>') : '&nbsp;') + '</div>';
                if (ok && !firstGroups && m && m.length > 1) firstGroups = { value: v, groups: m.slice(1) };
            });
            resultsEl.innerHTML = out;
            summaryEl.innerHTML = (re && total) ? ('<span class="' + (pass === total ? 'all' : (pass ? 'some' : 'none')) + '">' + pass + ' of ' + total + ' value' + (total === 1 ? '' : 's') + ' match</span>') : '';
            groupsEl.innerHTML = firstGroups
                ? '<span class="ppd-rxtest-glabel">Capture groups in “' + esc(firstGroups.value) + '”</span>' + firstGroups.groups.map(function (g, gi) { return '<span class="ppd-rxtest-g"><b>$' + (gi + 1) + '</b> ' + esc(g == null ? '(none)' : g) + '</span>'; }).join('')
                : '';
        }
        patEl.addEventListener('input', run); flagsEl.addEventListener('input', run); samplesEl.addEventListener('input', run);
        samplesEl.addEventListener('scroll', function () { resultsEl.scrollTop = samplesEl.scrollTop; });
        qsa('[data-exrx]', wrap).forEach(function (b) { b.addEventListener('click', function () { patEl.value = b.getAttribute('data-exrx'); flagsEl.value = b.getAttribute('data-exf') || ''; run(); patEl.focus(); }); });
        function close() { document.removeEventListener('keydown', onKey); wrap.remove(); }
        function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); close(); } }
        document.addEventListener('keydown', onKey);
        qsa('[data-cancel]', wrap).forEach(function (b) { b.addEventListener('click', close); });
        wrap.addEventListener('mousedown', function (e) { if (e.target === wrap) close(); });
        wrap.querySelector('[data-apply]').addEventListener('click', function () {
            var rules = (fcx.validation || []).slice(); if (!rules[i]) { close(); return; }
            rules[i].params = rules[i].params || {};
            rules[i].params.pattern = patEl.value.trim();
            rules[i].params.flags = (flagsEl.value || '').replace(/[^gimsuy]/g, '');
            fcx.validation = rules;
            close(); renderFcxValDetail();
        });
        run(); patEl.focus();
    }

    // Live tester for the other parameterised validators (In-list, Length,
    // Compare). Edit the settings, try sample values, then apply back to the rule.
    function openValidatorTester(i) {
        var r = (fcx.validation || [])[i]; if (!r) return;
        if (r.type === 'regex') return openRegexTester(i);
        if (!VTESTABLE[r.type]) return;
        r.params = r.params || {};
        var def = vDef(r.type) || {}, p = r.params;
        var titleIc = { inList: 'bi-list-check', length: 'bi-rulers', compare: 'bi-arrow-left-right' }[r.type] || 'bi-window-fullscreen';
        var body;
        if (r.type === 'inList') {
            body =
                '<div class="ppd-rxtest-patrow"><label class="ppd-rxtest-pat"><span>Allowed values (comma-separated)</span>' +
                  '<input type="text" data-p-values spellcheck="false" placeholder="active, inactive, pending"></label></div>' +
                '<label class="ppd-rxtest-ci"><input type="checkbox" data-p-ci> <span>Ignore case</span></label>' +
                '<div class="ppd-rxtest-chips" data-listchips></div>' +
                '<label class="ppd-rxtest-lbl">Test values <small>— one per line; each must be in the list</small></label>' +
                '<div class="ppd-rxtest-grid"><textarea data-rxsamples spellcheck="false" placeholder="Type values to test, one per line…"></textarea><div class="ppd-rxtest-results" data-rxresults></div></div>' +
                '<div class="ppd-rxtest-summary" data-rxsummary></div>';
        } else if (r.type === 'length') {
            body =
                '<div class="ppd-rxtest-patrow">' +
                  '<label class="ppd-rxtest-flags"><span>Min chars</span><input type="number" data-p-min min="0"></label>' +
                  '<label class="ppd-rxtest-flags"><span>Max chars</span><input type="number" data-p-max min="0"></label></div>' +
                '<label class="ppd-rxtest-lbl">Test values <small>— one per line; the length is shown</small></label>' +
                '<div class="ppd-rxtest-grid"><textarea data-rxsamples spellcheck="false" placeholder="Type values to test, one per line…"></textarea><div class="ppd-rxtest-results ppd-rxtest-len" data-rxresults></div></div>' +
                '<div class="ppd-rxtest-summary" data-rxsummary></div>';
        } else { // compare
            var opOpts = ['>', '>=', '<', '<=', '==', '!='].map(function (o) { return '<option>' + o + '</option>'; }).join('');
            var asOpts = ['number', 'date', 'text'].map(function (o) { return '<option>' + o + '</option>'; }).join('');
            body =
                '<div class="ppd-rxtest-patrow">' +
                  '<label class="ppd-rxtest-flags"><span>Must be</span><select data-p-op>' + opOpts + '</select></label>' +
                  '<label class="ppd-rxtest-flags"><span>Compare as</span><select data-p-as>' + asOpts + '</select></label></div>' +
                '<div class="ppd-rxtest-cmp">' +
                  '<label class="ppd-rxtest-pat"><span>This field\'s value</span><input type="text" data-cmp-this spellcheck="false"></label>' +
                  '<label class="ppd-rxtest-pat"><span>Other field (<code>' + esc(p.field || '?') + '</code>) value</span><input type="text" data-cmp-other spellcheck="false"></label></div>' +
                '<div class="ppd-rxtest-status" data-rxstatus></div>';
        }
        var wrap = document.createElement('div');
        wrap.className = 'ppd-modal ppd-rxtest';
        wrap.innerHTML =
            '<div class="ppd-modal-box ppd-rxtest-box" role="dialog" aria-label="Validation tester">' +
              '<div class="ppd-modal-head"><span><i class="bi ' + titleIc + '"></i> ' + esc(def.label || r.type) + ' — live test</span>' +
                '<button type="button" class="ppd-btn" data-cancel><i class="bi bi-x-lg"></i></button></div>' +
              '<div class="ppd-rxtest-body">' + body +
                '<div class="ppd-vr-rx-cheat">' + (VHELP[r.type] || '') + '</div></div>' +
              '<div class="ppd-modal-foot"><button type="button" class="ppd-btn" data-cancel>Cancel</button>' +
                '<button type="button" class="ppd-btn primary" data-apply><i class="bi bi-check-lg"></i> Use these settings</button></div>' +
            '</div>';
        document.body.appendChild(wrap);
        var samplesEl = wrap.querySelector('[data-rxsamples]'), resultsEl = wrap.querySelector('[data-rxresults]'),
            summaryEl = wrap.querySelector('[data-rxsummary]'), statusEl = wrap.querySelector('[data-rxstatus]');
        var run, apply;
        if (r.type === 'inList') {
            var valuesEl = wrap.querySelector('[data-p-values]'), ciEl = wrap.querySelector('[data-p-ci]'), chipsEl = wrap.querySelector('[data-listchips]');
            valuesEl.value = p.values || ''; ciEl.checked = (p.ci == null ? true : vFlag(p.ci));
            run = function () {
                var list = (valuesEl.value || '').split(',').map(function (s) { return s.trim(); }).filter(function (s) { return s !== ''; }), ci = ciEl.checked;
                chipsEl.innerHTML = list.length ? list.map(function (v) { return '<span class="ppd-rxtest-listchip">' + esc(v) + '</span>'; }).join('') : '<span class="ppd-rxtest-hint">Add some allowed values above.</span>';
                var cmp = ci ? list.map(function (s) { return s.toLowerCase(); }) : list, lines = samplesEl.value.split('\n'), out = '', pass = 0, total = 0;
                lines.forEach(function (v) {
                    if (v === '') { out += '<div class="ppd-rxtest-r is-blank">&nbsp;</div>'; return; }
                    total++; var ok = cmp.indexOf(ci ? v.toLowerCase() : v) > -1; if (ok) pass++;
                    out += '<div class="ppd-rxtest-r ' + (ok ? 'is-ok' : 'is-no') + '">' + (ok ? '<i class="bi bi-check-circle-fill"></i>' : '<i class="bi bi-x-circle-fill"></i>') + '</div>';
                });
                resultsEl.innerHTML = out;
                summaryEl.innerHTML = total ? '<span class="' + (pass === total ? 'all' : (pass ? 'some' : 'none')) + '">' + pass + ' of ' + total + ' in the list</span>' : '';
            };
            apply = function () { p.values = valuesEl.value; p.ci = ciEl.checked; };
        } else if (r.type === 'length') {
            var minEl = wrap.querySelector('[data-p-min]'), maxEl = wrap.querySelector('[data-p-max]');
            minEl.value = (p.min == null ? '' : p.min); maxEl.value = (p.max == null ? '' : p.max);
            run = function () {
                var mn = (minEl.value !== '' && !isNaN(+minEl.value)) ? +minEl.value : null, mx = (maxEl.value !== '' && !isNaN(+maxEl.value)) ? +maxEl.value : null;
                var lines = samplesEl.value.split('\n'), out = '', pass = 0, total = 0;
                lines.forEach(function (v) {
                    if (v === '') { out += '<div class="ppd-rxtest-r is-blank">&nbsp;</div>'; return; }
                    total++; var len = v.length, ok = (mn == null || len >= mn) && (mx == null || len <= mx); if (ok) pass++;
                    out += '<div class="ppd-rxtest-r ' + (ok ? 'is-ok' : 'is-no') + '"><b>' + len + '</b>' + (ok ? '<i class="bi bi-check-circle-fill"></i>' : '<i class="bi bi-x-circle-fill"></i>') + '</div>';
                });
                resultsEl.innerHTML = out;
                summaryEl.innerHTML = total ? '<span class="' + (pass === total ? 'all' : (pass ? 'some' : 'none')) + '">' + pass + ' of ' + total + ' within range' + ((mn != null || mx != null) ? ' (' + (mn != null ? 'min ' + mn : '') + (mn != null && mx != null ? ', ' : '') + (mx != null ? 'max ' + mx : '') + ')' : '') + '</span>' : '';
            };
            apply = function () { p.min = minEl.value; p.max = maxEl.value; };
        } else { // compare
            var opEl = wrap.querySelector('[data-p-op]'), asEl = wrap.querySelector('[data-p-as]'), thisEl = wrap.querySelector('[data-cmp-this]'), otherEl = wrap.querySelector('[data-cmp-other]');
            opEl.value = p.op || '>='; asEl.value = p.as || 'number';
            run = function () {
                var op = opEl.value, as = asEl.value, a = thisEl.value, b = otherEl.value;
                if (a === '' || b === '') { statusEl.className = 'ppd-rxtest-status'; statusEl.innerHTML = '<i class="bi bi-info-circle"></i> Enter both values to preview the result.'; return; }
                var va = a, vb = b;
                if (as === 'number') { va = parseFloat(a); vb = parseFloat(b); if (isNaN(va) || isNaN(vb)) { statusEl.className = 'ppd-rxtest-status is-err'; statusEl.innerHTML = '<i class="bi bi-exclamation-triangle-fill"></i> Both values must be numbers.'; return; } }
                else if (as === 'date') { va = Date.parse(a); vb = Date.parse(b); if (isNaN(va) || isNaN(vb)) { statusEl.className = 'ppd-rxtest-status is-err'; statusEl.innerHTML = '<i class="bi bi-exclamation-triangle-fill"></i> Both values must be valid dates.'; return; } }
                var ok = false;
                switch (op) {
                    case '>': ok = va > vb; break; case '>=': ok = va >= vb; break;
                    case '<': ok = va < vb; break; case '<=': ok = va <= vb; break;
                    case '==': ok = (as === 'text' ? a === b : va === vb); break;
                    case '!=': ok = (as === 'text' ? a !== b : va !== vb); break;
                }
                statusEl.className = 'ppd-rxtest-status ' + (ok ? 'is-ok' : 'is-err');
                statusEl.innerHTML = (ok ? '<i class="bi bi-check-circle-fill"></i> Passes' : '<i class="bi bi-x-circle-fill"></i> Fails') + ' — <code>' + esc(a) + ' ' + esc(op) + ' ' + esc(b) + '</code> as ' + esc(as);
            };
            apply = function () { p.op = opEl.value; p.as = asEl.value; };
        }
        qsa('input, select, textarea', wrap).forEach(function (el) {
            el.addEventListener((el.tagName === 'SELECT' || el.type === 'checkbox') ? 'change' : 'input', run);
        });
        if (samplesEl && resultsEl) samplesEl.addEventListener('scroll', function () { resultsEl.scrollTop = samplesEl.scrollTop; });
        function close() { document.removeEventListener('keydown', onKey); wrap.remove(); }
        function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); close(); } }
        document.addEventListener('keydown', onKey);
        qsa('[data-cancel]', wrap).forEach(function (b) { b.addEventListener('click', close); });
        wrap.addEventListener('mousedown', function (e) { if (e.target === wrap) close(); });
        wrap.querySelector('[data-apply]').addEventListener('click', function () {
            if (!(fcx.validation || [])[i]) { close(); return; }
            apply();
            fcx.validation = (fcx.validation || []).slice();
            close(); renderFcxValDetail();
        });
        run();
        var firstInput = wrap.querySelector('input, select, textarea'); if (firstInput) firstInput.focus();
    }

    // Compact conditional gate on a rule: "Only validate when <field> <op> <value>".
    var VWHEN_OPS = [['==', 'equals'], ['!=', 'is not'], ['notEmpty', 'is filled in'], ['empty', 'is empty'], ['in', 'is one of (csv)'], ['contains', 'contains']];
    function fcxWhenRow(i, r) {
        var w = r.when || {}; var op = w.op || '==';
        var noVal = (op === 'empty' || op === 'notEmpty');
        var ops = VWHEN_OPS.map(function (o) { return '<option value="' + o[0] + '"' + (o[0] === op ? ' selected' : '') + '>' + o[1] + '</option>'; }).join('');
        return '<div class="ppd-vr-when"><div class="ppd-vr-when-h"><i class="bi bi-signpost-split"></i> Only validate when</div>' +
            '<div class="ppd-vr-when-row">' +
                '<input class="ppd-vr-when-f" data-vrwhen="field" data-vr="' + i + '" value="' + esc(w.field || '') + '" placeholder="another field">' +
                '<select class="ppd-vr-when-op" data-vrwhen="op" data-vr="' + i + '">' + ops + '</select>' +
                '<input class="ppd-vr-when-v" data-vrwhen="value" data-vr="' + i + '" value="' + esc(w.value || '') + '" placeholder="value"' + (noVal ? ' hidden' : '') + '>' +
            '</div>' +
            '<div class="ppd-vr-when-hint">Leave the field blank to always apply this rule.</div></div>';
    }

    function renderFcxValDetail() {
        var stack = fcx.validation || [];
        var detail = fcxEl.querySelector('#ppdFcxDetail');
        var head = '<div class="ppd-fcx-detail-h"><i class="bi bi-shield-check"></i>' +
            '<div><strong>Validation rules</strong><small>' + (stack.length ? (stack.length + ' rule' + (stack.length === 1 ? '' : 's') + ' on “' + esc(fcx.field) + '”') : 'None yet') + '</small></div></div>';
        var body;
        if (!stack.length) {
            body = '<div class="ppd-fcx-empty"><i class="bi bi-shield-plus"></i> Pick a rule on the left to require this field, check its format, compare it to another field, and more. Rules run live in the browser and again on the server when the record is saved.</div>';
        } else {
            var rows = stack.map(function (r, i) {
                var def = vDef(r.type) || {}; var params = r.params || {};
                var desc = vRuleDesc(r.type);
                var phtml = (def.params || []).map(function (ps) {
                    var pv = params[ps.key]; if (pv == null && ps['default'] != null) pv = ps['default'];
                    return fcxVParamCtrl(i, ps, pv);
                }).join('');
                var opts = phtml ? '<div class="ppd-vr-opts"><div class="ppd-vr-opts-h">Options</div><div class="ppd-vr-params">' + phtml + '</div></div>' : '';
                var rx = r.type === 'regex' ? fcxRegexHelper(i) : '';
                // A "Live tester" button for the parameterised validators, so the
                // author can try their settings against sample values before saving.
                var tester = VTESTABLE[r.type] ? '<button type="button" class="ppd-vr-test-btn" data-vtest="' + i + '"><i class="bi bi-window-fullscreen"></i> Live tester</button>' : '';
                var dflt = (def.message || '').trim();
                return '<div class="ppd-vr">' +
                    '<div class="ppd-vr-head"><span class="ppd-vr-name"><i class="bi ' + vRuleIc(r.type) + '"></i> ' + esc(def.label || r.type) + '</span>' +
                    '<button type="button" class="ppd-vr-del" data-vr-del="' + i + '" title="Remove rule"><i class="bi bi-trash"></i></button></div>' +
                    (desc ? '<div class="ppd-vr-desc">' + esc(desc) + '</div>' : '') +
                    opts + rx + tester + fcxWhenRow(i, r) +
                    '<label class="ppd-vr-msglbl">Custom message <span>optional</span></label>' +
                    '<input class="ppd-vr-msg" data-vr="' + i + '" data-vrkey="message" value="' + esc(r.message || '') + '" placeholder="Message shown when this rule fails">' +
                    (dflt ? '<div class="ppd-vr-msghint">Leave blank to use: “' + esc(dflt) + '”</div>' : '') +
                    '</div>';
            }).join('');
            body = '<div class="ppd-fcm-body ppd-fcx-settings"><div class="ppd-vrs">' + rows +
                '<div class="ppd-note"><i class="bi bi-shield-check"></i> Checked live in the browser and enforced again on the server.</div></div></div>';
        }
        detail.innerHTML = head + body;

        qsa('.ppd-vr [data-vr]', detail).forEach(function (el) {
            var isChoice = el.type === 'checkbox' || el.tagName === 'SELECT';
            el.addEventListener(isChoice ? 'change' : 'input', function () {
                var i = parseInt(el.getAttribute('data-vr'), 10);
                var path = el.getAttribute('data-vrkey');
                var rules = (fcx.validation || []).slice();
                if (!rules[i]) return;
                var val = el.type === 'checkbox' ? el.checked : el.value;
                if (path === 'message') { rules[i].message = val; }
                else if (path.indexOf('params.') === 0) { rules[i].params = rules[i].params || {}; rules[i].params[path.slice(7)] = val; }
                fcx.validation = rules;
                if (path === 'params.pattern' || path === 'params.flags') fcxRegexResult(i);
            });
        });
        // Regex example chips → set the pattern (and flags) then re-render the card.
        qsa('.ppd-vr-chip', detail).forEach(function (b) {
            b.addEventListener('click', function () {
                var i = parseInt(b.getAttribute('data-rxi'), 10);
                var rules = (fcx.validation || []).slice(); if (!rules[i]) return;
                rules[i].params = rules[i].params || {};
                rules[i].params.pattern = b.getAttribute('data-rx');
                var f = b.getAttribute('data-rxf'); if (f) rules[i].params.flags = f;
                fcx.validation = rules;
                renderFcxValDetail();
            });
        });
        qsa('.ppd-vr-rx-testin', detail).forEach(function (el) {
            el.addEventListener('input', function () { fcxRegexResult(parseInt(el.getAttribute('data-rxi'), 10)); });
        });
        qsa('[data-rxopen]', detail).forEach(function (b) {
            b.addEventListener('click', function () { openRegexTester(parseInt(b.getAttribute('data-rxopen'), 10)); });
        });
        qsa('[data-vtest]', detail).forEach(function (b) {
            b.addEventListener('click', function () { openValidatorTester(parseInt(b.getAttribute('data-vtest'), 10)); });
        });
        // Conditional gate inputs → rules[i].when (cleared when the field is blank).
        qsa('[data-vrwhen]', detail).forEach(function (el) {
            var ev = (el.tagName === 'SELECT') ? 'change' : 'input';
            el.addEventListener(ev, function () {
                var i = parseInt(el.getAttribute('data-vr'), 10);
                var rules = (fcx.validation || []).slice(); if (!rules[i]) return;
                var w = Object.assign({ field: '', op: '==', value: '' }, rules[i].when || {});
                w[el.getAttribute('data-vrwhen')] = el.value;
                if ((w.field || '').trim() === '') { delete rules[i].when; }
                else { rules[i].when = { field: w.field, op: w.op, value: w.value }; }
                fcx.validation = rules;
                if (el.getAttribute('data-vrwhen') === 'op') renderFcxValDetail(); // toggle value box
            });
        });
        qsa('.ppd-vr-del', detail).forEach(function (b) {
            b.addEventListener('click', function () {
                var i = parseInt(b.getAttribute('data-vr-del'), 10);
                var rules = (fcx.validation || []).slice(); rules.splice(i, 1); fcx.validation = rules;
                renderFcxValList(); renderFcxValDetail(); syncFcxTabs();
            });
        });
    }

    function renderFcxDetail() {
        if (fcxActiveCat() === 'validation') return renderFcxValDetail();
        var cat = fcxActiveCat(), draft = fcxActiveDraft();
        var plugins = fcxPlugins(cat);
        var pl = plugins.filter(function (p) { return p.id === draft.type; })[0];
        var name = pl ? pl.label : (fcx.perPage ? 'Inherit' : 'Auto');
        var detail = fcxEl.querySelector('#ppdFcxDetail');

        var sub = draft.type ? (cat + ' control') : (fcx.perPage ? inheritNote(cat) : (FCX_AUTO[cat] || ''));
        var head = '<div class="ppd-fcx-detail-h"><i class="bi ' + (draft.type ? fcxIcon(draft.type) : (fcx.perPage ? 'bi-arrow-down-left-circle' : 'bi-magic')) + '"></i>' +
            '<div><strong>' + esc(name) + '</strong><small>' + esc(sub) + '</small></div></div>';

        var previewBox = '<div class="ppd-fcx-preview"><div class="ppd-fcx-preview-h"><i class="bi bi-eye"></i> Preview</div>' +
            '<div class="ppd-fcx-preview-bd" id="ppdFcxPrev"><span class="ppd-fcx-prev-wait">…</span></div></div>';

        var settings;
        if (pl && pl.settings && pl.settings.length) {
            settings = '<div class="ppd-fcm-body ppd-fcx-settings">' + fcSettingsBody(pl.settings, draft.settings, 'fcx.') + '</div>';
        } else if (draft.type === '') {
            settings = '<div class="ppd-fcx-empty"><i class="bi bi-info-circle"></i> ' +
                (fcx.perPage ? esc(inheritNote(cat)) + ' Pick a control on the left to override it for this page.' : (FCX_AUTO[cat] || 'Auto control.') + ' Pick a control on the left to customise.') + '</div>';
        } else {
            settings = '<div class="ppd-fcx-empty"><i class="bi bi-check2-circle"></i> This control has no extra options.</div>';
        }
        detail.innerHTML = head + previewBox + settings;

        // Settings inputs → draft.settings; choice inputs cascade + refresh preview.
        qsa('.ppd-fcx-settings [data-path]', detail).forEach(function (el) {
            var isChoice = el.type === 'checkbox' || el.type === 'radio' || el.tagName === 'SELECT' || el.type === 'color';
            el.addEventListener(isChoice ? 'change' : 'input', function () {
                var path = el.getAttribute('data-path');
                var key = path.indexOf('fcx.') === 0 ? path.slice(4) : path;
                draft.settings[key] = el.type === 'checkbox'
                    ? (el.getAttribute('data-ppbool') ? (el.checked ? 'yes' : 'no') : el.checked) : el.value;
                if (isChoice && el.type !== 'color') renderFcxDetail(); else runFcxPreview();
            });
        });
        wireRadioPills(detail);
        runFcxPreview();
    }

    // In per-page mode, describe what an inheriting page will use (the category default).
    function inheritNote(cat) {
        var d = fcx[cat] || {};
        var lbl = d.type ? ((fcxPlugins(cat).filter(function (p) { return p.id === d.type; })[0] || {}).label || d.type)
            : (cat === 'edit' ? 'a smart input' : cat === 'view' ? 'the plain value' : 'a text filter');
        return 'Inheriting the ' + (FCX_CAT_LABEL[cat] || cat) + ' default (' + lbl + ').';
    }

    function runFcxPreview() {
        clearTimeout(fcxPrevT);
        fcxPrevT = setTimeout(function () {
            if (!fcx || !fcxEl) return;
            var cat = fcxActiveCat(), draft = fcxActiveDraft(), reqKey = fcx.active;
            var box = fcxEl.querySelector('#ppdFcxPrev'); if (!box) return;
            if (!PPD.api || !PPD.api.fieldPreview) { box.innerHTML = '<span class="ppd-fcx-prev-wait">Preview unavailable</span>'; return; }
            // An inheriting per-page draft previews the category default it will use.
            var eff = draft.type ? draft : (fcx.perPage ? (fcx[cat] || draft) : draft);
            PPD.api.fieldPreview({ category: cat, control: eff.type, field: fcx.field, settings: eff.settings, value: null }).then(function (res) {
                if (!fcx || fcx.active !== reqKey) return;
                box.innerHTML = (res && res.html) ? res.html : '<span class="ppd-fcx-prev-wait">No preview</span>';
            }).catch(function () { box.innerHTML = '<span class="ppd-fcx-prev-wait">Preview unavailable</span>'; });
        }, 220);
    }

    function closeFcx() { if (fcxEl) fcxEl.hidden = true; fcx = null; clearTimeout(fcxPrevT); }

    function saveFcx() {
        if (!fcx || !PPD.api || !PPD.api.saveFieldControl) { closeFcx(); return; }
        var table = fcx.table, field = fcx.field, wasPerPage = fcx.perPage;
        var pack = function (d) { return (d && d.type) ? { type: d.type, settings: d.settings } : null; };
        var pages = {};
        FCX_PAGE_TABS.forEach(function (t) { pages[t.key] = pack(fcx.pages[t.key]); });
        var payload = { table: table, field: field, edit: pack(fcx.edit), view: pack(fcx.view), filter: pack(fcx.filter), validation: fcx.validation || [], pages: pages };
        var btn = fcxEl.querySelector('.ppd-fcx-save'); btn.disabled = true; btn.textContent = 'Saving…';
        PPD.api.saveFieldControl(payload).then(function (res) {
            btn.disabled = false; btn.textContent = 'Done';
            if (!res || !res.success) { if (PPD.toast) PPD.toast((res && res.message) || 'Could not save', 'error'); return; }
            var arr = store.fieldsFor(table);
            for (var i = 0; i < arr.length; i++) {
                if (arr[i].name === field) {
                    var f = res.field;
                    arr[i].editControl = f.editControl || ''; arr[i].editSettings = f.editSettings || {};
                    arr[i].viewControl = f.viewControl || ''; arr[i].viewSettings = f.viewSettings || {};
                    arr[i].filterControl = f.filterControl || ''; arr[i].filterSettings = f.filterSettings || {};
                    arr[i].validations = Array.isArray(f.validations) ? f.validations : [];
                    arr[i].pageAs = f.pageAs || {};
                    break;
                }
            }
            closeFcx();
            if (PPD.renderer && PPD.renderer.render) PPD.renderer.render();
            if (PPD.toast) PPD.toast(wasPerPage ? 'Per-page controls saved' : 'Controls saved — applied to all pages', 'success');
        }).catch(function () { btn.disabled = false; btn.textContent = 'Done'; if (PPD.toast) PPD.toast('Could not save', 'error'); });
    }

    function openFieldControls(table, field, label) {
        if (!table || !field) return;
        var arr = store.fieldsFor(table), fld = null;
        for (var i = 0; i < arr.length; i++) { if (arr[i].name === field) { fld = arr[i]; break; } }
        var cp = function (t, s) { return { type: (fld && fld[t]) || '', settings: JSON.parse(JSON.stringify((fld && fld[s]) || {})) }; };
        var pa = (fld && fld.pageAs) || {}, pages = {};
        FCX_PAGE_TABS.forEach(function (t) {
            var o = pa[t.key];
            pages[t.key] = (o && o.type) ? { type: o.type, settings: JSON.parse(JSON.stringify(o.settings || {})) } : { type: '', settings: {} };
        });
        var hasPerPage = FCX_PAGE_TABS.some(function (t) { return pages[t.key].type; });
        var vrules = (fld && Array.isArray(fld.validations)) ? JSON.parse(JSON.stringify(fld.validations)) : [];
        fcx = { table: table, field: field, q: '', perPage: hasPerPage,
            edit: cp('editControl', 'editSettings'), view: cp('viewControl', 'viewSettings'), filter: cp('filterControl', 'filterSettings'),
            validation: vrules, pages: pages };
        fcx.active = fcxTabDefs()[0].key;
        if (!fcxEl) fcxEl = buildFcxModal();
        fcxEl.querySelector('#ppdFcxTitle').textContent = 'Field controls — ' + ((fld && fld.label) || label || field);
        fcxEl.querySelector('#ppdFcxSub').textContent = table + ' · ' + field;
        fcxEl.querySelector('#ppdFcxPerPage').checked = fcx.perPage;
        renderFcxTabs(); renderFcxList(); renderFcxDetail(); updateFcxFoot();
        fcxEl.hidden = false;
    }

    // plugin control picker for a grid column (writes via part.<ctrlKey> / part.<settingsKey>.<k>)
    function partControlEditor(col, category, ctrlKey, settingsKey) {
        var plugins = (window.PpDesignerBootstrap && window.PpDesignerBootstrap.plugins && window.PpDesignerBootstrap.plugins[category]) || [];
        var curId = (col && col[ctrlKey]) || '';
        var opts = '<option value="">— default —</option>' + plugins.map(function (pl) {
            return '<option value="' + esc(pl.id) + '"' + (pl.id === curId ? ' selected' : '') + '>' + esc(pl.label) + '</option>';
        }).join('');
        var out = field('Control', '<select data-path="part.' + ctrlKey + '">' + opts + '</select>');
        var pl = plugins.filter(function (p) { return p.id === curId; })[0];
        if (pl && pl.settings && pl.settings.length) {
            var saved = (col && col[settingsKey]) || {};
            out += '<div class="ppd-fc-settings">' + fcSettingsBody(pl.settings, saved, 'part.' + settingsKey + '.') + '</div>';
        }
        return out;
    }

    /* ---------- Master–Detail relation section (view/edit canvas) ---------- */
    // Not a model component — an auto section from Tables → Relations whose
    // styling is stored per-page in page.relationStyles[relId].
    function renderRelationSection(host, page, relId) {
        var rel = (((window.PpDesignerBootstrap || {}).relations) || []).filter(function (r) { return r.id === relId; })[0] || {};
        var rs = ((page.relationStyles || {})[relId]) || {};
        setType('Detail section · ' + (rel.detail_table || ''));
        var body = group('Detail section', [
            field('Title', input('text', 'relstyle.title', rs.title || '', false, title(rel.detail_table || ''))),
            field('Header icon', iconPicker('relstyle.headerIcon', rs.headerIcon || 'bi-diagram-3'))
        ]) + group('Section', [
            field('Background', colorInput('relstyle.bg', rs.bg || '')),
            field('Border color', colorInput('relstyle.border', rs.border || '')),
            field('Corner radius', input('text', 'relstyle.radius', rs.radius || '', false, 'e.g. 12px')),
            field('Padding', input('text', 'relstyle.pad', rs.pad || '', false, 'e.g. 14px'))
        ]) + group('Header', [
            field('Header background', colorInput('relstyle.headerBg', rs.headerBg || '')),
            field('Header text', colorInput('relstyle.headerColor', rs.headerColor || '')),
            checkRow([chk('relstyle.hideHeader', 'Hide the title bar', !!rs.hideHeader)])
        ]) + group('Grid', [
            checkRow([chk('relstyle.striped', 'Striped rows', !!rs.striped), chk('relstyle.compact', 'Compact rows', !!rs.compact)])
        ]) + '<div class="ppd-note"><i class="bi bi-diagram-3"></i> Auto detail section from <b>Tables → Relations</b>: <b>' +
            esc(rel.master_table || '') + '.' + esc(rel.master_field || 'id') + '</b> → <b>' + esc(rel.detail_table || '') + '.' + esc(rel.detail_field || '') +
            '</b>. Its behaviour (which pages, add button, counts) is set in the Relation modal — here you style how it looks on this page.</div>';
        host.innerHTML = head('Detail section', (rel.detail_table || '') + ' · relation') +
            '<div class="ppd-insp-body">' + body + idBox('relation: ' + relId) + '</div>';
        bind(host);
    }

    // Part editors inside a view/edit relation section (rs* parts on the
    // synthetic rel:<id> selection). All values live in page.relationStyles.
    function renderRelationPart(host, page, relId, partStr) {
        var seg = partStr.split(':'); var ptype = seg[0]; var pkey = seg.slice(1).join(':');
        var rel = (((window.PpDesignerBootstrap || {}).relations) || []).filter(function (r) { return r.id === relId; })[0] || {};
        var rs = ((page.relationStyles || {})[relId]) || {};
        setType('Detail section › ' + ptype.replace(/^rs/, ''));
        var titleLabel, body;
        if (ptype === 'rstitle') {
            titleLabel = 'Detail title';
            body = group('Title', [
                field('Text', input('text', 'relstyle.title', rs.title || '', false, title(rel.detail_table || ''))),
                field('Icon', iconPicker('relstyle.headerIcon', rs.headerIcon || 'bi-diagram-3')),
                field('Text color', colorInput('relstyle.titleColor', rs.titleColor || '')),
                checkRow([chk('relstyle.titleBold', 'Extra bold', !!rs.titleBold)])
            ]);
        } else if (ptype === 'rsbadge') {
            titleLabel = 'Record-count badge';
            body = group('Count badge', [
                field('Size', select('relstyle.badgeSize', ['sm', 'md', 'lg'], rs.badgeSize || 'md')),
                field('Shape', select('relstyle.badgeShape', ['pill', 'rounded', 'square', 'circle'], rs.badgeShape || 'pill')),
                field('Background', colorInput('relstyle.badgeBg', rs.badgeBg || '')),
                field('Text color', colorInput('relstyle.badgeColor', rs.badgeColor || '')),
                checkRow([chk('relstyle.badgeHide', 'Hide the badge', !!rs.badgeHide)])
            ]);
        } else if (ptype === 'rsadd' || ptype === 'rsgo') {
            var bp = ptype === 'rsadd' ? 'add' : 'go';
            titleLabel = ptype === 'rsadd' ? 'Add button' : 'View-all button';
            body = group('Button', [
                field('Label', input('text', 'relstyle.' + bp + 'Label', rs[bp + 'Label'] || '', false, ptype === 'rsadd' ? 'Add / Inline Add' : 'View all')),
                field('Icon', iconPicker('relstyle.' + bp + 'Icon', rs[bp + 'Icon'] || (ptype === 'rsadd' ? 'bi-plus-lg' : 'bi-box-arrow-up-right'))),
                field('Background', colorInput('relstyle.' + bp + 'Bg', rs[bp + 'Bg'] || '')),
                field('Text color', colorInput('relstyle.' + bp + 'Color', rs[bp + 'Color'] || '')),
                checkRow([chk('relstyle.' + bp + 'Hide', 'Hide this button', !!rs[bp + 'Hide'])])
            ]) + '<div class="ppd-note"><i class="bi bi-info-circle"></i> ' + (ptype === 'rsadd'
                ? 'Runtime shows <b>Inline Add</b> when the child table has it enabled in Pages, else a regular Add link.'
                : 'Appears when the relation enables <b>Show “Proceed to Details”</b> in Tables → Relations.') + '</div>';
        } else if (ptype === 'rsgrid') {
            titleLabel = 'Detail table';
            body = group('Child grid', [
                checkRow([chk('relstyle.striped', 'Striped rows', !!rs.striped), chk('relstyle.compact', 'Compact rows', !!rs.compact)]),
                checkRow([chk('relstyle.hover', 'Highlight row on hover', !!rs.hover)]),
                field('Max rows shown', input('number', 'relstyle.limit', rs.limit || '', false, '10')),
                field('Order (by id)', select('relstyle.orderDir', ['ASC', 'DESC'], rs.orderDir || 'ASC'))
            ]) + '<div class="ppd-note"><i class="bi bi-info-circle"></i> Click a <b>column header</b> to edit that column — or drag it to reorder.</div>';
        } else if (ptype === 'rshead') {
            titleLabel = 'Detail header';
            body = group('Header bar', [
                field('Background', colorInput('relstyle.headerBg', rs.headerBg || '')),
                field('Text color', colorInput('relstyle.headerColor', rs.headerColor || '')),
                checkRow([chk('relstyle.hideHeader', 'Hide the header bar', !!rs.hideHeader)])
            ]) + '<div class="ppd-note"><i class="bi bi-info-circle"></i> Click the <b>title</b>, <b>badge</b> or a <b>button</b> inside the bar to edit each one.</div>';
        } else if (ptype === 'rscol') {
            var rcCols = reg.relCols ? reg.relCols(rs, rel) : [];
            var rcCol = rcCols.filter(function (c) { return c.field === pkey; })[0] || { field: pkey };
            titleLabel = 'Child column · ' + pkey;
            body = group('Column', [
                field('Field', input('text', '', pkey, true)),
                field('Label', input('text', 'relcol.' + pkey + '.label', rcCol.label || '', false, title(pkey))),
                field('Align', select('relcol.' + pkey + '.align', ['left', 'center', 'right'], rcCol.align || 'left')),
                field('Width', input('text', 'relcol.' + pkey + '.width', rcCol.width || '', false, 'auto / 120px')),
                checkRow([chk('relcol.' + pkey + '.visible', 'Show column', rcCol.visible !== false)])
            ]) + '<div class="ppd-note"><i class="bi bi-arrows-move"></i> Drag the column header on the canvas to reorder.</div>';
        } else {
            titleLabel = title(ptype);
            body = '<div class="ppd-muted">Select the section itself to style it.</div>';
        }
        host.innerHTML = head(titleLabel, (rel.detail_table || '') + ' · relation part') +
            '<div class="ppd-insp-body"><button type="button" class="ppd-back" data-back><i class="bi bi-arrow-left"></i> Detail section</button>' +
            body + idBox('relation: ' + relId + '\npart: ' + partStr) + '</div>';
        bind(host);
    }

    /* ---------- sub-part editor (deep selection) ---------- */
    // Selectable Card Gallery (card-grid) parts → inspector labels.
    var CARD_PARTS = { cbox: 'Card', cmedia: 'Image', chead: 'Card header', ctitle: 'Title', csub: 'Subtitle', cbadge: 'Badge', cbody: 'Body', cfoot: 'Footer / actions' };

    // Friendly labels for composite-widget sub-elements (data-ppd-part="el:<key>").
    var EL_PART_LABELS = {
        'stat-card': { value: 'Value', caption: 'Caption', icon: 'Icon' },
        'kpi-stat': { value: 'Value', caption: 'Caption', icon: 'Icon' },
        'greeting': { title: 'Title', subtitle: 'Subtitle', date: 'Date' },
        'alert': { icon: 'Icon', text: 'Text' },
        'status-badge': { text: 'Text' },
        'toast': { title: 'Title', text: 'Text' },
        'progress': { label: 'Label' },
        'progress-card': { label: 'Title', value: 'Percent', hint: 'Hint' },
        'avatar': { icon: 'Icon', name: 'Name', subtitle: 'Subtitle' },
        'kpi-trend-card': { label: 'Label', value: 'Value', delta: 'Delta' },
        'task-list': { heading: 'Heading' },
        'quick-links': { heading: 'Heading' },
        'recent-activity': { heading: 'Heading' },
        'notification-list': { heading: 'Heading' },
        'mini-table': { heading: 'Heading' },
        'calendar-widget': { heading: 'Heading' }
    };
    // Style bag for one sub-element — a focused subset that BOTH the canvas
    // (registry.partStyleCss) and the build (ComponentRenderer::partStyleCss)
    // honor, so what you style here is exactly what ships.
    function elStyleFields(s) {
        s = s || {};
        return [
            field('Text color', colorInput('part.color', s.color || '')),
            field('Background', colorInput('part.background', s.background || '')),
            field('Font', fontSelect('part.font', s.font || '')),
            field('Font size', input('text', 'part.fontSize', s.fontSize || '', false, 'e.g. 28px')),
            field('Font weight', select('part.fontWeight', ['', 'normal', '500', '600', '700', '800'], s.fontWeight || '')),
            field('Align', select('part.align', ['', 'left', 'center', 'right'], s.align || '')),
            field('Padding', input('text', 'part.padding', s.padding || '', false, 'e.g. 4px 0')),
            field('CSS class', input('text', 'part.class', s['class'] || ''))
        ];
    }

    function renderPart(host, node, partStr) {
        var seg = partStr.split(':'); var ptype = seg[0]; var pkey = seg.slice(1).join(':');
        setType(title(node.type) + ' › ' + ptype);
        var titleLabel, body;

        // Menu Card zone (header / description / footer) → a focused panel that
        // edits that zone's EXISTING props (headerBg, descColor, footerText…) via
        // the normal cmp.prop path, so the build (mcZoneStyle) is unchanged.
        if (node.type === 'menu-card' && ptype === 'mcz') {
            var mcZones = {
                header: { title: 'Header', fields: [
                    { key: 'heading', label: 'Heading', type: 'text' },
                    { key: 'icon', label: 'Header icon', type: 'icon' },
                    { 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: ['left', 'center', 'right'] }
                ] },
                desc: { 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: ['left', 'center', 'right'] }
                ] },
                footer: { title: 'Footer', fields: [
                    { key: 'footerIcon', label: 'Footer icon', type: 'icon' },
                    { key: 'footerText', label: 'Footer text', type: 'text' },
                    { 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: ['left', 'center', 'right'] }
                ] }
            };
            var mcz = mcZones[pkey] || mcZones.header;
            setType('Menu Card › ' + mcz.title);
            host.innerHTML = head(mcz.title + ' — Menu Card', (node.label || 'Menu Card') + ' · ' + pkey) +
                '<div class="ppd-insp-body"><button type="button" class="ppd-back" data-back><i class="bi bi-arrow-left"></i> ' + esc(node.label || 'Menu Card') + '</button>' +
                group(mcz.title, mcz.fields.map(function (f) { return renderField(node, f); })) +
                '<div class="ppd-note"><i class="bi bi-info-circle"></i> Styling the <b>' + esc(mcz.title) + '</b> zone. Click the card background for all settings; blank fields inherit the theme.</div>' +
                idBox('component: ' + node.id + '\npart: ' + partStr) + '</div>';
            bind(host, node);
            return;
        }
        // Composite-widget sub-element (Stat Card value/caption/icon, Hero
        // title/subtitle…) → a focused element-style panel writing to
        // props.partStyles[<key>]. Generic across every widget that emits el: parts.
        if (ptype === 'el') {
            var elLbl = (EL_PART_LABELS[node.type] && EL_PART_LABELS[node.type][pkey]) || title(pkey);
            var elBag = (node.props.partStyles || {})[pkey] || {};
            setType(title(node.type) + ' › ' + elLbl);
            host.innerHTML = head(elLbl, (node.label || title(node.type)) + ' · el:' + pkey) +
                '<div class="ppd-insp-body"><button type="button" class="ppd-back" data-back><i class="bi bi-arrow-left"></i> ' + esc(node.label || title(node.type)) + '</button>' +
                group('Style · ' + elLbl, elStyleFields(elBag)) +
                '<div class="ppd-note"><i class="bi bi-info-circle"></i> Styling just the <b>' + esc(elLbl) + '</b> of this component. Blank fields inherit the theme; the generated app matches exactly.</div>' +
                idBox('component: ' + node.id + '\npart: ' + partStr) + '</div>';
            bind(host, node);
            return;
        }

        // Summary (KPI) card → a focused Size & Layout panel giving THIS card its
        // own responsive width. Writes items[<idx>].spanD/spanT/spanM via the
        // generic list.<key>.<idx>.<sub> path; the render/build turn those into
        // grid-column spans (12-col grid) per device. This is the "select a card,
        // size it per screen" flow.
        if (node.type === 'summary' && ptype === 'kpi') {
            var kidx = parseInt(pkey, 10) || 0;
            var kit = (node.props.items || [])[kidx] || {};
            var klbl = String(kit.label || '').trim() || ('Card ' + (kidx + 1));
            var kopts = [['', 'Auto (strip default)'], ['12', 'Full — 1 / row'], ['6', 'Half — 2 / row'], ['4', 'Third — 3 / row'], ['3', 'Quarter — 4 / row'], ['2', 'Sixth — 6 / row']];
            var krow = function (sub, ic, lbl) {
                var cur = String(kit[sub] == null ? '' : kit[sub]);
                var sel = '<select data-path="list.items.' + kidx + '.' + sub + '">' + kopts.map(function (o) {
                    return '<option value="' + o[0] + '"' + (o[0] === cur ? ' selected' : '') + '>' + esc(o[1]) + '</option>';
                }).join('') + '</select>';
                return '<div class="ppd-field-row"><span><i class="bi ' + ic + '"></i> ' + lbl + '</span>' + sel + '</div>';
            };
            setType('Summary › ' + klbl);
            host.innerHTML = head(klbl + ' — width', (node.label || 'Summary') + ' · card ' + (kidx + 1)) +
                '<div class="ppd-insp-body"><button type="button" class="ppd-back" data-back><i class="bi bi-arrow-left"></i> ' + esc(node.label || 'Summary') + '</button>' +
                group('Size & layout', [
                    '<div class="ppd-note"><i class="bi bi-arrows-angle-expand"></i> How wide this card is on each screen. <b>Auto</b> follows the strip. Setting any card switches the whole strip to a responsive 12-column grid.</div>',
                    krow('spanD', 'bi-display', 'Desktop'),
                    krow('spanT', 'bi-tablet', 'Tablet'),
                    krow('spanM', 'bi-phone', 'Mobile')
                ]) +
                '<div class="ppd-note"><i class="bi bi-lightbulb"></i> Tip: switch the device in the top bar to preview each size. Edit this card\'s value, label and colour from the <b>Summary</b> item list (click Back).</div>' +
                idBox('component: ' + node.id + '\npart: ' + partStr) + '</div>';
            bind(host, node);
            return;
        }

        // Calendar button (calendar-app) → a focused panel to style one button
        // (New event / Create): label, icon, colours, font, size.
        if (node.type === 'calendar-app' && ptype === 'btn') {
            var calBtn = (PPD.calendarButtons && PPD.calendarButtons[pkey]) || null;
            if (!calBtn) { renderComponent(host, node); return; }
            var btnFields = calBtn.fields.map(function (f) { return renderField(node, f); });
            setType(title(node.type) + ' › button');
            host.innerHTML = head(calBtn.title, (node.label || 'Calendar') + ' · btn:' + pkey) +
                '<div class="ppd-insp-body"><button type="button" class="ppd-back" data-back><i class="bi bi-arrow-left"></i> ' + esc(node.label || 'Calendar') + '</button>' +
                group(calBtn.title, btnFields) +
                '<div class="ppd-note"><i class="bi bi-info-circle"></i> Styling the <b>' + esc(calBtn.title) + '</b>. Blank fields inherit the theme. The generated app’s button matches exactly.</div>' +
                idBox('component: ' + node.id + '\npart: ' + partStr) + '</div>';
            bind(host, node);
            return;
        }

        // Calendar region (calendar-app) → a focused panel for one clickable
        // region (header / toolbar / grid). Fields come from the SAME registry
        // group as the whole-page inspector, so writes route through the normal
        // cmp.prop.* path the build reads.
        if (node.type === 'calendar-app' && ptype === 'region') {
            var calGrp = (PPD.calendarRegions && PPD.calendarRegions.groupFor(pkey)) || null;
            if (!calGrp) { renderComponent(host, node); return; }
            var calFields = calGrp.fields.map(function (f) { return renderField(node, f); });
            titleLabel = calGrp.title;
            setType(title(node.type) + ' › ' + titleLabel);
            host.innerHTML = head(titleLabel + ' — Calendar', (node.label || 'Calendar') + ' · ' + pkey) +
                '<div class="ppd-insp-body"><button type="button" class="ppd-back" data-back><i class="bi bi-arrow-left"></i> ' + esc(node.label || 'Calendar') + '</button>' +
                group(titleLabel, calFields) +
                '<div class="ppd-note"><i class="bi bi-info-circle"></i> Editing the <b>' + esc(titleLabel) + '</b> region. Click the calendar background for all settings; use “Reset to default” on the page to restore the template.</div>' +
                idBox('component: ' + node.id + '\npart: ' + partStr) + '</div>';
            bind(host, node);
            return;
        }

        // Filter Tabs (where-tabs) → one individual tab, styled by position.
        // Overrides layer over the component-wide style (blank = inherit).
        if (node.type === 'where-tabs' && ptype === 'tab') {
            var wtI = parseInt(pkey, 10) || 0;
            var wtList = store.whereTabsFor ? store.whereTabsFor(node.table || (model.activePage() || {}).table) : [];
            var wtName = (wtList[wtI] || {}).name || '';
            var wtO = (node.props.tabStyles || {})[wtI] || {};
            titleLabel = 'Tab ' + (wtI + 1) + (wtName ? ' — ' + wtName : '');
            body = group('This tab', [
                field('Text colour', colorInput('part.tabColor', wtO.tabColor || ''), 'Overrides this tab’s label colour.'),
                field('Background', colorInput('part.tabBg', wtO.tabBg || ''), 'This tab’s background.'),
                field('Font size', input('text', 'part.fontSize', wtO.fontSize || '', false, 'e.g. 14px'), 'CSS size, e.g. 14px or .9rem.'),
                field('Font', fontSelect('part.fontFamily', wtO.fontFamily || ''), 'Bundled web fonts + safe system stacks.'),
                field('Padding', input('text', 'part.padding', wtO.padding || '', false, 'e.g. .4rem .9rem'), 'CSS padding: vertical horizontal.')
            ]) + group('This tab’s badge', [
                field('Text colour', colorInput('part.badgeColor', wtO.badgeColor || ''), 'The count badge’s text colour.'),
                field('Background', colorInput('part.badgeBg', wtO.badgeBg || ''), 'The count badge’s background (adds a pill).'),
                field('Font size', input('text', 'part.badgeSize', wtO.badgeSize || '', false, 'e.g. 11px'), 'Badge text size.')
            ]) + '<div class="ppd-note"><i class="bi bi-info-circle"></i> Styles this tab only. Blank fields inherit the component-wide style. Tabs are matched by position.</div>';
            host.innerHTML = head(titleLabel, (node.label || 'Filter Tabs') + ' · ' + partStr) +
                '<div class="ppd-insp-body"><button type="button" class="ppd-back" data-back><i class="bi bi-arrow-left"></i> ' + esc(node.label || 'Filter Tabs') + '</button>' +
                body + idBox('component: ' + node.id + '\npart: ' + partStr) + '</div>';
            bind(host, node);
            return;
        }

        // Cell Grid cell (layout-grid) — NOT the data-grid's value cell, which
        // also uses the 'cell' part prefix (guarded by node.type).
        if (ptype === 'cell' && node.type === 'layout-grid') {
            var lgR = parseInt(seg[1], 10) || 0, lgC = parseInt(seg[2], 10) || 0;
            var lgCfg = (node.props.cells || {})[lgR + ':' + lgC] || {};
            var lgMerged = (Math.max(1, parseInt(lgCfg.rs, 10) || 1) > 1) || (Math.max(1, parseInt(lgCfg.cs, 10) || 1) > 1);
            titleLabel = 'Cell ' + (lgR + 1) + ' × ' + (lgC + 1) + (lgMerged ? ' (merged)' : '');
            // Per-device width in 12ths (the standard 12-column system, matching
            // regular components). Desktop defaults to the cell's proportional
            // width so switching to Responsive columns keeps the layout.
            var lgCols = Math.max(1, parseInt(node.props.cols, 10) || 2);
            var lgCsCur = Math.max(1, parseInt(lgCfg.cs, 10) || 1);
            var lgW12 = [[12, 'Full · 12/12'], [11, '11/12'], [10, '⅚ · 10/12'], [9, '¾ · 9/12'], [8, '⅔ · 8/12'], [7, '7/12'], [6, '½ · 6/12'], [5, '5/12'], [4, '⅓ · 4/12'], [3, '¼ · 3/12'], [2, '⅙ · 2/12'], [1, '1/12']];
            var lgW12t = [['', 'Same as desktop']].concat(lgW12);
            var lgWd = lgCfg.w ? Math.max(1, Math.min(12, parseInt(lgCfg.w, 10))) : Math.max(1, Math.min(12, Math.round(lgCsCur / lgCols * 12)));
            var lgBtn = function (op, ic, lbl, danger) {
                return '<button type="button" class="ppd-btn' + (danger ? ' danger' : '') + '" data-cellop="' + op + '"><i class="bi ' + ic + '"></i> ' + esc(lbl) + '</button>';
            };
            var lgSel = String(store.ui('selectedCells') || '').split(',').filter(Boolean);
            body = group('Merge & split', [
                '<div class="ppd-cellops">'
                + (lgSel.length > 1
                    ? lgBtn('merge-selected', 'bi-union', 'Merge ' + lgSel.length + ' selected cells')
                    : '')
                + lgBtn('merge-right', 'bi-arrow-bar-right', 'Merge right')
                + lgBtn('merge-down', 'bi-arrow-bar-down', 'Merge down')
                + lgBtn('unmerge', 'bi-grid-3x3', 'Split (unmerge)')
                + '</div>'
                + '<div class="ppd-note" style="margin:6px 0 0"><i class="bi bi-command"></i> '
                + '⌘/Ctrl-click more cells on the canvas to multi-select, then merge them in one go. '
                + 'Arrow keys move between cells, Enter opens the cell’s component, Esc selects the grid. '
                + 'Drag the vertical grips between columns to resize them.</div>'
            ]) + group('Rows & columns here', [
                '<div class="ppd-cellops">'
                + lgBtn('row-above', 'bi-chevron-bar-up', 'Row above')
                + lgBtn('row-below', 'bi-chevron-bar-down', 'Row below')
                + lgBtn('col-left', 'bi-chevron-bar-left', 'Column left')
                + lgBtn('col-right', 'bi-chevron-bar-right', 'Column right')
                + lgBtn('delrow', 'bi-trash', 'Delete row', true)
                + lgBtn('delcol', 'bi-trash', 'Delete column', true)
                + '</div>'
            ]) + group('Size & layout', [
                '<div class="ppd-field-row"><span><i class="bi bi-display"></i> Desktop' + tipIcon('This cell’s width in 12ths. On desktop the grid keeps your exact layout; on tablet/mobile cells reflow to these widths — e.g. ½ = 6/12 so two cells sit side by side.') + '</span>' + select('part.w', lgW12, lgWd) + '</div>',
                '<div class="ppd-field-row"><span><i class="bi bi-tablet"></i> Tablet</span>' + select('part.wT', lgW12t, lgCfg.wT || '') + '</div>',
                '<div class="ppd-field-row"><span><i class="bi bi-phone"></i> Mobile</span>' + select('part.wM', lgW12t, lgCfg.wM || '') + '</div>',
                '<div class="ppd-note" style="margin:4px 0 8px"><i class="bi bi-phone"></i> Tablet/Mobile widths apply automatically on smaller screens. Leave <b>Same as desktop</b> to keep this cell’s width, or pick <b>Full · 12/12</b> to make it stack.</div>',
                field('Min height', input('text', 'part.minH', lgCfg.minH || '', false, 'e.g. 80px')),
                field('Height', input('text', 'part.h', lgCfg.h || '', false, 'e.g. 200px (fixed)')),
                field('Max height', input('text', 'part.maxH', lgCfg.maxH || '', false, 'e.g. 300px (scrolls)')),
                field('Padding', input('text', 'part.pad', lgCfg.pad || '', false, 'e.g. 10px or 8px 12px')),
                field('Margin', input('text', 'part.margin', lgCfg.margin || '', false, 'e.g. 0 0 12px'))
            ]) + group('Cell style', [
                field('Horizontal align', select('part.align', [['full', 'Full width'], ['left', 'Left'], ['center', 'Center'], ['right', 'Right']], lgCfg.align || 'full'), 'How the component inside sits across the cell. Full width fills the cell; Left/Center/Right keep the component its natural width and place it on that side.'),
                field('Vertical align', select('part.valign', ['', 'top', 'middle', 'bottom'], lgCfg.valign || '')),
                field('Background', colorInput('part.bg', lgCfg.bg || '')),
                field('Text colour', colorInput('part.color', lgCfg.color || '')),
                field('Border', input('text', 'part.border', lgCfg.border || '', false, '1px solid #e2e8f0'))
            ]) + group('Responsive (hide on)', [
                '<div class="ppd-field-row"><span><i class="bi bi-eye-slash"></i> Hide on' + tipIcon('Remove this cell entirely on that screen size. Breakpoints: desktop ≥1024px · tablet 768–1023px · mobile <768px.') + '</span><span class="ppd-checks">'
                    + chk('part.hideM', 'Mobile', !!lgCfg.hideM) + chk('part.hideT', 'Tablet', !!lgCfg.hideT) + chk('part.hideD', 'Desktop', !!lgCfg.hideD) + '</span></div>'
            ]) + '<div class="ppd-note"><i class="bi bi-info-circle"></i> Drop fields or components into the cell — hover a cell and click its <b>corner tag</b>, or <b>Alt-click</b> anywhere inside, to select it.</div>';
        } else if (ptype === 'head') {
            var ch = colOf(node, pkey); var h = ch.header || {};
            titleLabel = 'Header cell · ' + (ch.label || title(pkey));
            body = gridLabelsGroup(pkey, ch.label) + group('Header text', [
                field('Field', input('text', '', pkey, true)),
                field('Width', input('text', 'part.width', ch.width || 'auto', false, 'auto / 120px')),
                field('Pin', select('part.pin', ['none', 'left', 'right'], ch.pin || 'none')),
                checkRow([chk('part.sortable', 'Sortable', ch.sortable !== false), chk('part.searchable', 'Searchable', ch.searchable !== false), chk('part.visible', 'Show column', ch.visible !== false), chk('part.filter', 'Header filter', ch.filter === true)]),
                '<div class="ppd-note"><i class="bi bi-funnel"></i> <b>Header filter</b> adds a searchable, multi-select value dropdown to this column’s header (Excel-style, exact match).</div>'
            ]) + group('Header style', [
                field('Align', select('part.align', ['left', 'center', 'right'], h.align || ch.align || 'left'))
            ].concat(cellStyleFields(h)));
        } else if (ptype === 'filter') {
            var cfc = colOf(node, pkey); var fconf = cfc.filterConfig || {};
            titleLabel = 'Filter · ' + (cfc.label || title(pkey));
            body = group('Column filter', [
                field('Field', input('text', '', pkey, true)),
                field('Type', select('part.type', ['text', 'select', 'number', 'date', 'daterange'], fconf.type || 'text')),
                field('Placeholder', input('text', 'part.placeholder', fconf.placeholder || ''))
            ]);
        } else if (ptype === 'cell') {
            var cc0 = colOf(node, pkey); var cc = cc0.cell || {};
            titleLabel = 'Value cell · ' + (cc0.label || title(pkey));
            body = group('Field value', [
                field('Field', input('text', '', pkey, true)),
                field('Align', select('part.align', ['left', 'center', 'right'], cc.align || 'left')),
                field('Format', input('text', 'part.format', cc.format || '', false, 'date / currency / number'))
            ]) + group('Cell style', cellStyleFields(cc));
        } else if (ptype === 'field') {
            var fcol = colOf(node, pkey); var vsf = fcol.fieldStyle || {};
            titleLabel = 'Field · ' + (fcol.label || title(pkey));
            body = gridLabelsGroup(pkey, fcol.label) +
                group('Field', [ field('DB field', input('text', '', pkey, true)) ]) +
                group('Display control (view)', [ partControlEditor(fcol, 'view', 'viewControl', 'viewSettings') ]) +
                group('Input control (edit / inline)', [ partControlEditor(fcol, 'edit', 'editControl', 'editSettings') ]) +
                group('Filter control (list filter)', [ partControlEditor(fcol, 'filter', 'filterControl', 'filterSettings') ]) +
                group('Field style (visual markup)', [
                    field('Text', colorInput('part.color', vsf.color || '')),
                    field('Background', colorInput('part.background', vsf.background || '')),
                    field('Font', fontSelect('part.font', vsf.font || '')),
                    field('Font size', input('text', 'part.fontSize', vsf.fontSize || '', false, 'e.g. 14px')),
                    field('Font weight', select('part.fontWeight', ['', 'normal', '500', '600', '700', '800'], vsf.fontWeight || '')),
                    field('Padding', input('text', 'part.padding', vsf.padding || '', false, 'e.g. 2px 8px')),
                    field('Border', input('text', 'part.border', vsf.border || '', false, 'e.g. 1px solid #ddd')),
                    field('Border radius', input('text', 'part.borderRadius', vsf.borderRadius || '', false, 'e.g. 6px')),
                    field('CSS class', input('text', 'part.class', vsf['class'] || '')),
                    checkRow([chk('part.bold', 'Bold', !!vsf.bold)]),
                    fieldFull('Custom CSS (this part)', textarea('part.customCss', vsf.customCss || ''))
                ]) + '<div class="ppd-note"><i class="bi bi-info-circle"></i> This styles the field placeholder only. Select the surrounding cell to style the whole cell.</div>';
        } else if (ptype === 'foot') {
            var cf0 = colOf(node, pkey); var f = cf0.footer || {};
            titleLabel = 'Aggregate cell · ' + (cf0.label || title(pkey));
            body = group('Aggregate', [
                field('Field', input('text', '', pkey, true)),
                field('Function', select('part.aggregate', ['none', 'sum', 'avg', 'count', 'min', 'max'], f.aggregate || 'none')),
                field('Scope', select('part.aggregateScope', ['all records', 'displayed records'], node.props.aggregateScope || 'all records')),
                field('Label', input('text', 'part.text', f.text || '', false, 'overrides function label')),
                field('Align', select('part.align', ['left', 'center', 'right'], f.align || 'left'))
            ]) + group('Cell style', cellStyleFields(f));
        } else if (ptype === 'header' || ptype === 'body' || ptype === 'footer') {
            var lvlOf = { header: 'header', body: 'cell', footer: 'footer' };
            var rd = ((node.props.rows || {})[lvlOf[ptype]]) || {};
            var labelOf = { header: 'Header row (field names)', body: 'Field / value row', footer: 'Aggregate row' };
            titleLabel = labelOf[ptype];
            body = group('Row defaults', [
                field('Align all', select('part.align', ['left', 'center', 'right'], rd.align || 'left')),
                field('Row height', input('text', 'part.height', rd.height || '', false, 'e.g. 44px'))
            ].concat(cellStyleFields(rd))) + (ptype === 'footer' ? '<div class="ppd-checks">' + chk('part.showTotals', 'Show aggregate row at runtime', node.props.showTotals !== false) + '</div>' : '') +
                '<div class="ppd-note"><i class="bi bi-info-circle"></i> Row defaults apply to every cell here. Select an individual cell to override it.</div>';
        } else if (ptype === 'selcol') {
            titleLabel = 'Selection column';
            body = '<div class="ppd-checks">' + chk('part.selection', 'Enable row selection', node.props.selection !== false) + '</div>';
        } else if (ptype === 'actionsHeader') {
            titleLabel = 'Actions column';
            body = group('Row actions', [
                field('Actions', input('text', 'part.actions', (node.props.actions || ['view', 'edit', 'delete']).join(', '), false, 'view, edit, delete'))
            ]) + '<div class="ppd-checks">' + chk('part.showActions', 'Show actions column', node.props.showActions !== false) + '</div>';
        } else if (ptype === 'tb') {
            var tm = reg.resolve('toolbarctrl', pkey, node.props.config);
            titleLabel = 'Toolbar · ' + (tm.label || title(pkey));
            if (tm.type === 'search') {
                body = group('Search box', [
                    field('Design', select('part.searchStyle', ['glass', 'minimal', 'soft', 'outline', 'pill'], node.props.searchStyle || 'glass'), 'The search field look. Glass = frosted (default) · Minimal = underline · Soft = filled · Outline = bordered · Pill = fully rounded.'),
                    field('Width', input('text', 'part.searchWidth', node.props.searchWidth || '', false, 'e.g. 260px · 40% · auto'), 'Fix the field width on desktop — shrink it when you have many controls, or widen it when you have room. Blank = flexible. Mobile always fills its row.'),
                    field('Placeholder', input('text', 'part.searchPlaceholder', node.props.searchPlaceholder || 'Search records...')),
                    checkRow([chk('part.visible', 'Visible', tm.visible !== false)])
                ]) + '<div class="ppd-note"><i class="bi bi-info-circle"></i> The design applies to this list’s search field in the generated app.</div>';
            } else if (tm.type === 'info') {
                body = group('Records count', [
                    fieldFull('Format', input('text', 'part.recordInfo', node.props.recordInfo || 'Displaying %from% – %to% of %total%')),
                    checkRow([chk('part.visible', 'Visible', tm.visible !== false)])
                ]) + '<div class="ppd-note"><i class="bi bi-info-circle"></i> Tokens: <b>%from%</b>, <b>%to%</b>, <b>%total%</b> are filled with live numbers at runtime.</div>';
            } else if (tm.type === 'density') {
                body = group('Density menu', [
                    field('Label', input('text', 'part.label', tm.label || 'Density')),
                    field('Default', select('part.density', ['compact', 'normal', 'comfortable'], node.props.density || 'compact')),
                    checkRow([chk('part.visible', 'Visible', tm.visible !== false)])
                ]);
            } else if (tm.type === 'columns') {
                body = group('Columns menu', [
                    field('Label', input('text', 'part.label', tm.label || 'Columns')),
                    field('Icon', iconPicker('part.icon', tm.icon || 'bi-layout-three-columns')),
                    checkRow([chk('part.visible', 'Visible', tm.visible !== false)])
                ]) + '<div class="ppd-note"><i class="bi bi-info-circle"></i> Lets end-users show/hide grid columns at runtime. The list mirrors the Data Grid\'s columns.</div>';
            } else if (tm.type === 'export') {
                body = group('Export', [
                    field('Label', input('text', 'part.label', tm.label || 'Export')),
                    field('Icon', iconPicker('part.icon', tm.icon || 'bi-download')),
                    field('Formats', input('text', 'part.exportFormats', (node.props.exportFormats || []).join(', '), false, 'xlsx, word, csv')),
                    checkRow([chk('part.visible', 'Visible', tm.visible !== false)])
                ]);
            } else if (tm.type === 'page_size') {
                body = group('Records per page', [
                    field('Label', input('text', 'part.label', tm.label || 'Show')),
                    field('Options', input('text', 'part.pageSizes', (node.props.pageSizes || []).join(', '), false, '10, 20, 50, all')),
                    field('Default', input('text', 'part.pageSizeDefault', node.props.pageSizeDefault || 20)),
                    checkRow([chk('part.visible', 'Visible', tm.visible !== false)])
                ]);
            } else {
                body = group('Button', [
                    field('Label', input('text', 'part.label', tm.label || '')),
                    field('Icon', iconPicker('part.icon', tm.icon || '')),
                    field('Variant', select('part.variant', ['default', 'primary', 'danger'], tm.variant || 'default')),
                    field('Display', select('part.displayMode', ['icon', 'label', 'icon_label'], tm.displayMode || 'icon_label')),
                    field('Tooltip', input('text', 'part.tooltip', tm.tooltip || '')),
                    checkRow([chk('part.visible', 'Visible', tm.visible !== false), chk('part.requiresSelection', 'Requires selection', tm.requiresSelection === true)])
                ]);
            }
        } else if (ptype === 'selact') {
            var sm = reg.resolve('bulk', pkey, node.props.actionConfig);
            titleLabel = 'Bulk action · ' + (sm.label || title(pkey));
            body = group('Bulk button', [
                field('Action', input('text', '', pkey, true)),
                field('Label', input('text', 'part.label', sm.label || '')),
                field('Icon', iconPicker('part.icon', sm.icon || '')),
                field('Variant', select('part.variant', ['default', 'primary', 'danger'], sm.variant || 'default')),
                field('Display', select('part.displayMode', ['icon', 'label', 'icon_label'], sm.displayMode || 'icon_label')),
                checkRow([chk('part.visible', 'Visible', sm.visible !== false)])
            ]);
        } else if (ptype === 'action') {
            var m = reg.resolve('action', pkey, node.props.actionConfig);
            titleLabel = 'Row action · ' + (m.label || title(pkey));
            body = group('Action', [
                field('Action', input('text', '', pkey, true)),
                field('Label', input('text', 'part.label', m.label || '')),
                field('Icon', iconPicker('part.icon', m.icon || '')),
                field('Display', select('part.displayMode', ['icon', 'label', 'icon_label'], m.displayMode || 'icon')),
                field('Tooltip', input('text', 'part.tooltip', m.tooltip || '')),
                field('CSS class', input('text', 'part.class', m['class'] || '')),
                checkRow([chk('part.visible', 'Visible', m.visible !== false), chk('part.danger', 'Danger style', !!m.danger)])
            ]);
        } else if (ptype === 'button') {
            var grp = node.type === 'toolbar' ? 'toolbar' : 'formbtn';
            var bm = reg.resolve(grp, pkey, node.props.buttonConfig);
            titleLabel = 'Button · ' + (bm.label || title(pkey));
            body = group('Button', [
                field('Key', input('text', '', pkey, true)),
                field('Label', input('text', 'part.label', bm.label || '')),
                field('Icon', iconPicker('part.icon', bm.icon || '')),
                field('Variant', select('part.variant', ['default', 'primary', 'danger'], bm.variant || 'default')),
                field('Display', select('part.displayMode', ['icon', 'label', 'icon_label'], bm.displayMode || 'icon_label')),
                field('Tooltip', input('text', 'part.tooltip', bm.tooltip || '')),
                checkRow([chk('part.visible', 'Visible', bm.visible !== false)])
            ]);
        } else if (ptype === 'item') {
            titleLabel = 'Breadcrumb item';
            body = group('Item', [field('Text', input('text', 'part.text', (node.props.items || [])[+pkey] || ''))]);
        } else if (ptype === 'tab') {
            titleLabel = 'Tab';
            body = group('Tab', [field('Label', input('text', 'part.label', (node.props.tabs || [])[+pkey] || ''))]);
        } else if (ptype === 'kpi') {
            var it = (node.props.items || [])[+pkey] || {};
            titleLabel = 'KPI · ' + (it.label || '');
            var kb = window.PpDesignerBootstrap || {};
            var kTbl = String(it.table || '');
            var kTblOpts = '<option value="">— none (static value) —</option>' + (kb.tables || []).map(function (t) {
                return '<option' + (t.name === kTbl ? ' selected' : '') + '>' + esc(t.name) + '</option>';
            }).join('');
            var kColOpts = '<option value="">' + (kTbl ? '— choose column —' : 'pick a table first') + '</option>' +
                (((kb.fieldsByTable || {})[kTbl]) || []).map(function (fld) {
                    return '<option' + (fld.name === String(it.column || '') ? ' selected' : '') + '>' + esc(fld.name) + '</option>';
                }).join('');
            body = group('KPI tile', [
                field('Label', input('text', 'part.label', it.label || '')),
                field('Icon', iconPicker('part.icon', it.icon || '')),
                field('Value color', colorInput('part.color', it.color || ''))
            ]) + group('Data binding (live value)', [
                field('Table', '<select data-path="part.table">' + kTblOpts + '</select>'),
                field('Aggregate', select('part.agg', ['count', 'sum', 'avg', 'min', 'max'], it.agg || 'count')),
                field('Column (for sum/avg…)', '<select data-path="part.column">' + kColOpts + '</select>'),
                fieldFull('Where (condition)', input('text', 'part.where', it.where || '', false, "status = 'active'  ·  created >= :daysago.30  ·  owner_id = :userid") + '<small class="ppd-field-note">Filter the value. Supports SQL variables — <code>:userid</code>, <code>:today</code>, <code>:daysago.30</code>, <code>:session.key</code>.</small>'),
                field('Prefix', input('text', 'part.prefix', it.prefix || '', false, 'e.g. $')),
                field('Suffix', input('text', 'part.suffix', it.suffix || '', false, 'e.g. pcs'))
            ]) + group('Static value (when not bound)', [
                field('Value', input('text', 'part.value', it.value || ''))
            ]);
        } else if (ptype === 'caption') {
            titleLabel = 'Grid caption';
            body = group('Caption', [field('Text', input('text', 'part.text', node.props.caption || ''))]);
        } else if (ptype === 'dgtitle' || ptype === 'dgbadge' || ptype === 'dgadd' || ptype === 'dggo' || ptype === 'dgcol') {
            // Detail-grid component parts — values live directly in props
            // (same keys the build's ComponentRenderer reads).
            var dgp = node.props || {};
            if (ptype === 'dgtitle') {
                titleLabel = 'Detail title';
                body = group('Title', [
                    field('Text', input('text', 'part.title', dgp.title || '', false, title(dgp.table || ''))),
                    field('Icon', iconPicker('part.icon', dgp.icon || 'bi-diagram-3')),
                    field('Text color', colorInput('part.titleColor', dgp.titleColor || '')),
                    checkRow([chk('part.titleBold', 'Extra bold', !!dgp.titleBold)])
                ]);
            } else if (ptype === 'dgbadge') {
                titleLabel = 'Record-count badge';
                body = group('Count badge', [
                    field('Size', select('part.badgeSize', ['sm', 'md', 'lg'], dgp.badgeSize || 'md')),
                    field('Shape', select('part.badgeShape', ['pill', 'rounded', 'square', 'circle'], dgp.badgeShape || 'pill')),
                    field('Background', colorInput('part.badgeBg', dgp.badgeBg || '')),
                    field('Text color', colorInput('part.badgeColor', dgp.badgeColor || '')),
                    checkRow([chk('part.badgeHide', 'Hide the badge', !!dgp.badgeHide)])
                ]);
            } else if (ptype === 'dgadd' || ptype === 'dggo') {
                var dbp = ptype === 'dgadd' ? 'add' : 'go';
                titleLabel = ptype === 'dgadd' ? 'Add button' : 'View-all button';
                body = group('Button', [
                    field('Label', input('text', 'part.' + dbp + 'Label', dgp[dbp + 'Label'] || '', false, ptype === 'dgadd' ? 'Add / Inline Add' : 'View all')),
                    field('Icon', iconPicker('part.' + dbp + 'Icon', dgp[dbp + 'Icon'] || (ptype === 'dgadd' ? 'bi-plus-lg' : 'bi-box-arrow-up-right'))),
                    field('Background', colorInput('part.' + dbp + 'Bg', dgp[dbp + 'Bg'] || '')),
                    field('Text color', colorInput('part.' + dbp + 'Color', dgp[dbp + 'Color'] || '')),
                    checkRow([chk('part.' + dbp + 'Hide', 'Hide this button', !!dgp[dbp + 'Hide'])])
                ]) + '<div class="ppd-note"><i class="bi bi-info-circle"></i> ' + (ptype === 'dgadd'
                    ? 'Shown when <b>Add button</b> is enabled on this component; runtime uses Inline Add when the child table has it.'
                    : 'Shown when <b>“View all” link</b> is enabled on this component.') + '</div>';
            } else {
                var dgCols2 = reg.dgCols ? reg.dgCols(dgp) : [];
                var dgCol = dgCols2.filter(function (c) { return c.field === pkey; })[0] || { field: pkey };
                titleLabel = 'Child column · ' + pkey;
                body = group('Column', [
                    field('Field', input('text', '', pkey, true)),
                    field('Label', input('text', 'part.label', dgCol.label || '', false, title(pkey))),
                    field('Align', select('part.align', ['left', 'center', 'right'], dgCol.align || 'left')),
                    field('Width', input('text', 'part.width', dgCol.width || '', false, 'auto / 120px')),
                    checkRow([chk('part.visible', 'Show column', dgCol.visible !== false)])
                ]) + '<div class="ppd-note"><i class="bi bi-arrows-move"></i> Drag the column header on the canvas to reorder. Lookup fields keep showing display values automatically.</div>';
            }
        } else if (ptype.indexOf('mdprev') === 0 || ptype === 'mdpreview') {
            // Zone + component editors for the expandable child/detail preview
            // panel (all backed by detailsCol.preview.*, via applyPartValue).
            var mpv = (node.props.detailsCol || {}).preview || {};
            if (ptype === 'mdpreview') {
                titleLabel = 'Detail section';
                body = group('Section', [
                    field('Background', colorInput('part.bg', mpv.bg || '')),
                    field('Border color', colorInput('part.border', mpv.border || '')),
                    field('Corner radius', input('text', 'part.radius', mpv.radius || '', false, 'e.g. 12px')),
                    field('Padding', input('text', 'part.pad', mpv.pad || '', false, 'e.g. 14px'))
                ]) + '<div class="ppd-note"><i class="bi bi-info-circle"></i> The whole child/detail panel that expands under a row. Click its <b>header components</b>, the <b>table</b> or a <b>column</b> to style those.</div>' +
                '<div class="ppd-reset-row"><button type="button" class="ppd-btn danger" data-md-reset><i class="bi bi-arrow-counterclockwise"></i> Reset detail-section styling</button></div>';
            } else if (ptype === 'mdprevhead') {
                titleLabel = 'Detail header';
                body = group('Header bar', [
                    field('Background', colorInput('part.headerBg', mpv.headerBg || '')),
                    field('Text color', colorInput('part.headerColor', mpv.headerColor || '')),
                    checkRow([chk('part.hideHeader', 'Hide the header bar', !!mpv.hideHeader)])
                ]) + '<div class="ppd-note"><i class="bi bi-info-circle"></i> Click the <b>title</b>, <b>count badge</b>, <b>Inline Add</b> or <b>Proceed</b> button inside the bar to edit each one.</div>';
            } else if (ptype === 'mdprevtitle') {
                titleLabel = 'Detail title';
                body = group('Title', [
                    field('Text', input('text', 'part.title', mpv.title || '', false, 'auto from table name')),
                    field('Icon', iconPicker('part.headerIcon', mpv.headerIcon || 'bi-diagram-3')),
                    field('Text color', colorInput('part.titleColor', mpv.titleColor || '')),
                    checkRow([chk('part.titleBold', 'Extra bold', !!mpv.titleBold)])
                ]);
            } else if (ptype === 'mdprevbadge') {
                titleLabel = 'Record-count badge';
                body = group('Count badge', [
                    field('Size', select('part.badgeSize', ['sm', 'md', 'lg'], mpv.badgeSize || 'md')),
                    field('Shape', select('part.badgeShape', ['pill', 'rounded', 'square', 'circle'], mpv.badgeShape || 'pill')),
                    field('Background', colorInput('part.badgeBg', mpv.badgeBg || '')),
                    field('Text color', colorInput('part.badgeColor', mpv.badgeColor || '')),
                    checkRow([chk('part.badgeHide', 'Hide the badge', !!mpv.badgeHide)])
                ]) + '<div class="ppd-note"><i class="bi bi-info-circle"></i> Shows the live child-record count at runtime.</div>';
            } else if (ptype === 'mdprevadd' || ptype === 'mdprevgo') {
                var bp = ptype === 'mdprevadd' ? 'add' : 'go';
                titleLabel = ptype === 'mdprevadd' ? 'Inline Add button' : 'Proceed button';
                body = group('Button', [
                    field('Label', input('text', 'part.' + bp + 'Label', mpv[bp + 'Label'] || '', false, ptype === 'mdprevadd' ? 'Inline Add' : 'Proceed to details')),
                    field('Icon', iconPicker('part.' + bp + 'Icon', mpv[bp + 'Icon'] || (ptype === 'mdprevadd' ? 'bi-plus-lg' : 'bi-box-arrow-up-right'))),
                    field('Background', colorInput('part.' + bp + 'Bg', mpv[bp + 'Bg'] || '')),
                    field('Text color', colorInput('part.' + bp + 'Color', mpv[bp + 'Color'] || '')),
                    checkRow([chk('part.' + bp + 'Hide', 'Hide this button', !!mpv[bp + 'Hide'])])
                ]) + '<div class="ppd-note"><i class="bi bi-info-circle"></i> ' + (ptype === 'mdprevadd'
                    ? 'Appears when the child table has <b>Inline Add</b> enabled in the Pages module.'
                    : 'Appears when the relation has <b>Show “Proceed to Details”</b> enabled in Tables → Relations.') + '</div>';
            } else if (ptype === 'mdprevcol') {
                var seg2 = pkey.split(':');
                var mcRelId = seg2[0], mcField = seg2.slice(1).join(':');
                var mcRel = (((window.PpDesignerBootstrap || {}).relations) || []).filter(function (x) { return x.id === mcRelId; })[0] || {};
                var mcCols = reg.mdPrevCols ? reg.mdPrevCols(mpv, mcRel) : [];
                var mcCol = mcCols.filter(function (c) { return c.field === mcField; })[0] || { field: mcField };
                titleLabel = 'Child column · ' + mcField;
                body = group('Column', [
                    field('Field', input('text', '', mcField, true)),
                    field('Label', input('text', 'part.label', mcCol.label || '', false, title(mcField))),
                    field('Align', select('part.align', ['left', 'center', 'right'], mcCol.align || 'left')),
                    field('Width', input('text', 'part.width', mcCol.width || '', false, 'auto / 120px')),
                    checkRow([chk('part.visible', 'Show column', mcCol.visible !== false)])
                ]) + '<div class="ppd-note"><i class="bi bi-arrows-move"></i> Drag the column header on the canvas to reorder. Lookup fields keep showing display values automatically.</div>';
            } else {
                titleLabel = 'Detail table';
                body = group('Child grid', [
                    checkRow([chk('part.striped', 'Striped rows', !!mpv.striped), chk('part.compact', 'Compact rows', !!mpv.compact)]),
                    checkRow([chk('part.hover', 'Highlight row on hover', !!mpv.hover)]),
                    field('Max rows shown', input('number', 'part.limit', mpv.limit || '', false, '25')),
                    field('Order (by id)', select('part.orderDir', ['ASC', 'DESC'], mpv.orderDir || 'ASC'))
                ]) + '<div class="ppd-note"><i class="bi bi-info-circle"></i> Click a <b>column header</b> to edit that column (label, align, width, visibility) — or drag it to reorder.</div>';
            }
        } else if (ptype === 'mdhead') {
            var mdc = node.props.detailsCol || {};
            var pv = mdc.preview || {};
            titleLabel = 'Details column';
            body = group('Details column', [
                field('Header label', input('text', 'part.label', mdc.label || '', false, 'Details')),
                field('Position', select('part.position', ['start', 'end'], mdc.position || 'start')),
                field('Width', input('text', 'part.width', mdc.width || '', false, 'auto / 120px')),
                field('Align', select('part.align', ['left', 'center', 'right'], mdc.align || 'left')),
                checkRow([chk('part.hidden', 'Hide on this page', !!mdc.hidden)])
            ]) + group('Preview · section', [
                field('Background', colorInput('part.preview.bg', pv.bg || '')),
                field('Border color', colorInput('part.preview.border', pv.border || '')),
                field('Corner radius', input('text', 'part.preview.radius', pv.radius || '', false, 'e.g. 12px')),
                field('Padding', input('text', 'part.preview.pad', pv.pad || '', false, 'e.g. 14px'))
            ]) + group('Preview · header', [
                field('Header icon', iconPicker('part.preview.headerIcon', pv.headerIcon || 'bi-diagram-3')),
                field('Header background', colorInput('part.preview.headerBg', pv.headerBg || '')),
                field('Header text', colorInput('part.preview.headerColor', pv.headerColor || '')),
                checkRow([chk('part.preview.hideHeader', 'Hide the title bar', !!pv.hideHeader)])
            ]) + group('Preview · grid', [
                checkRow([chk('part.preview.striped', 'Striped rows', !!pv.striped), chk('part.preview.compact', 'Compact rows', !!pv.compact)])
            ]) + '<div class="ppd-note"><i class="bi bi-info-circle"></i> <b>Preview</b> styles the expandable child panel shown when a user clicks a Details link (works in light & dark themes). Which relations appear — and their behaviour (pages, counts) — is defined in <b>Tables → Relations</b>. Select a link below the header to style it.</div>';
        } else if (ptype === 'mdlink') {
            var mdc2 = node.props.detailsCol || {};
            var mdRel = (((window.PpDesignerBootstrap || {}).relations) || []).filter(function (r) { return r.id === pkey; })[0] || {};
            var mdOv = ((mdc2.perRel || {})[pkey]) || {};
            var mdSingle2 = (mdRel.details_link_mode || 'single') === 'single';
            titleLabel = 'Details link · ' + (mdRel.detail_table || pkey);
            body = group('Link', [
                field('Label', input('text', 'part.label', mdOv.label || '', false, mdSingle2 ? 'Details' : title(mdRel.detail_table || ''))),
                field('Icon', iconPicker('part.icon', mdOv.icon || mdc2.icon || 'bi-chevron-right')),
                field('Text color', colorInput('part.linkColor', mdc2.linkColor || '')),
                checkRow([chk('part.showLabel', 'Show label', mdOv.showLabel !== false), chk('part.showIcon', 'Show icon', mdc2.showIcon !== false)]),
                checkRow([chk('part.bold', 'Bold', !!mdc2.bold), chk('part.underline', 'Underline', mdc2.underline !== false)])
            ]) + group('Count badge', [
                field('Badge', select('part.badge', ['auto', 'show', 'hide'], mdc2.badge || 'auto')),
                field('Size', select('part.badgeSize', ['sm', 'md', 'lg'], mdc2.badgeSize || 'md')),
                field('Shape', select('part.badgeShape', ['pill', 'rounded', 'square', 'circle'], mdc2.badgeShape || (mdc2.badgePill === false ? 'rounded' : 'pill'))),
                field('Background', colorInput('part.badgeBg', mdc2.badgeBg || (mdRel.count_badge_color || ''))),
                field('Text color', colorInput('part.badgeColor', mdc2.badgeColor || ''))
            ]) + '<div class="ppd-note"><i class="bi bi-diagram-3"></i> Relation: <b>' + esc(mdRel.master_table || '') + '.' + esc(mdRel.master_field || 'id') + '</b> → <b>' + esc(mdRel.detail_table || '') + '.' + esc(mdRel.detail_field || '') + '</b> · display: ' + esc(mdRel.display_option || 'inline') + '. “Auto” badge follows the relation’s <i>Display child record count</i> setting. Untick <i>Show label</i> for a badge-only link.</div>';
        } else if (CARD_PARTS[ptype] || ptype === 'cact') {
            // Card Gallery sub-parts (header, title, badge, body, footer, and each
            // action button cact:<action>) — styled individually, stored under
            // card-grid props.partStyles keyed by the part (cact keeps its action).
            var cKey = (ptype === 'cact') ? partStr : ptype;
            var cps = (node.props.partStyles || {})[cKey] || {};
            titleLabel = (ptype === 'cact') ? (title(pkey) + ' button') : CARD_PARTS[ptype];
            var cfJump = '<button type="button" class="ppd-cf-jump" data-jump-cf><i class="bi bi-lightning-charge"></i> Conditional formatting</button>';
            body = group(titleLabel + ' style', [
                field('Align', select('part.align', ['', 'left', 'center', 'right'], cps.align || '')),
                field('Border radius', input('text', 'part.borderRadius', cps.borderRadius || '', false, 'e.g. 8px'))
            ].concat(cellStyleFields(cps))) +
                '<div class="ppd-note"><i class="bi bi-info-circle"></i> ' + (ptype === 'cact'
                    ? 'Styles the <b>' + esc(title(pkey)) + '</b> button on every card. Select <b>Footer / actions</b> to style all buttons at once.'
                    : 'Styles this part on every card.') + ' To restyle by field value per record, use ' + cfJump + '</div>';
        } else {
            titleLabel = title(ptype);
            body = '<div class="ppd-muted">This element belongs to “' + esc(node.label || node.type) + '”. Select the component to edit it.</div>';
        }

        host.innerHTML = head(titleLabel, node.label + ' · ' + partStr) +
            '<div class="ppd-insp-body"><button type="button" class="ppd-back" data-back><i class="bi bi-arrow-left"></i> ' + esc(node.label || title(node.type)) + '</button>' +
            body + idBox('component: ' + node.id + '\npart: ' + partStr) + '</div>';
        bind(host, node);
    }

    function getCols(node) {
        if (node.props.columns && node.props.columns.length) return node.props.columns;
        return store.fieldsFor((model.activePage() || {}).table).slice(0, 8).map(function (f) { return { field: f.name, label: f.label || title(f.name), visible: true, sortable: true }; });
    }
    function colOf(node, field) { return getCols(node).filter(function (c) { return c.field === field; })[0] || { field: field }; }

    function applyPartValue(node, partStr, key, value) {
        var seg = partStr.split(':'); var ptype = seg[0]; var pkey = seg.slice(1).join(':');
        // Composite-widget sub-element style → props.partStyles[<key>].<key>
        if (ptype === 'el') {
            var ps = Object.assign({}, node.props.partStyles || {});
            ps[pkey] = Object.assign({}, ps[pkey] || {});
            if (value === '' || value == null) delete ps[pkey][key]; else ps[pkey][key] = value;
            model.updateProps(node.id, { partStyles: ps });
            return;
        }
        // Filter Tabs per-tab style → props.tabStyles[i].<key>
        if (node.type === 'where-tabs' && ptype === 'tab') {
            var wI = parseInt(seg[1], 10) || 0;
            var ts = Object.assign({}, node.props.tabStyles || {});
            ts[wI] = Object.assign({}, ts[wI] || {}); ts[wI][key] = value;
            model.updateProps(node.id, { tabStyles: ts });
            return;
        }
        // Cell Grid cell style → props.cells["r:c"].<key>
        if (node.type === 'layout-grid' && ptype === 'cell') {
            var lgKey = (parseInt(seg[1], 10) || 0) + ':' + (parseInt(seg[2], 10) || 0);
            var lgCells = Object.assign({}, node.props.cells || {});
            lgCells[lgKey] = Object.assign({}, lgCells[lgKey] || {});
            lgCells[lgKey][key] = value;
            model.updateProps(node.id, { cells: lgCells });
            return;
        }
        // write a column property, optionally nested under a level (header/cell/footer)
        function setCol(field, level, k, v) {
            var cols = getCols(node).map(function (c) { return Object.assign({}, c); });
            var idx = -1; cols.forEach(function (c, i) { if (c.field === field) idx = i; });
            if (idx < 0) return;
            if (level) { cols[idx][level] = Object.assign({}, cols[idx][level]); cols[idx][level][k] = v; }
            else cols[idx][k] = v;
            model.updateProps(node.id, { columns: cols });
        }
        // write a row-level default (rows.header / rows.cell / rows.footer)
        function setRow(level, k, v) {
            var rows = Object.assign({}, node.props.rows || {});
            rows[level] = Object.assign({}, rows[level]); rows[level][k] = v;
            model.updateProps(node.id, { rows: rows });
        }
        var COL_TOP = ['label', 'width', 'sortable', 'searchable', 'visible', 'pin', 'filter'];
        if (ptype === 'head') {
            if (key === 'pin' && value === 'none') value = '';
            setCol(pkey, COL_TOP.indexOf(key) > -1 ? null : 'header', key, value);
        }
        else if (ptype === 'cell') { setCol(pkey, 'cell', key, value); }
        else if (ptype === 'field') {   // the field placeholder inside a value cell
            if (key === 'viewControl' || key === 'editControl' || key === 'filterControl') setCol(pkey, null, key, value);
            else if (key.indexOf('viewSettings.') === 0) setCol(pkey, 'viewSettings', key.slice(13), value);
            else if (key.indexOf('editSettings.') === 0) setCol(pkey, 'editSettings', key.slice(13), value);
            else if (key.indexOf('filterSettings.') === 0) setCol(pkey, 'filterSettings', key.slice(15), value);
            else setCol(pkey, 'fieldStyle', key, value);   // color / background / bold
        }
        else if (ptype === 'foot') {
            // aggregate scope is a grid-level setting, surfaced in the cell panel for convenience
            if (key === 'aggregateScope') { model.updateProps(node.id, { aggregateScope: value }); }
            else { setCol(pkey, 'footer', key, value); }
        }
        else if (ptype === 'filter') { setCol(pkey, 'filterConfig', key, value); }
        else if (ptype === 'header') { setRow('header', key, value); }
        else if (ptype === 'body') { setRow('cell', key, value); }
        else if (ptype === 'footer') { if (key === 'showTotals') model.updateProps(node.id, { showTotals: value }); else setRow('footer', key, value); }
        else if (ptype === 'selcol') { if (key === 'selection') model.updateProps(node.id, { selection: value }); }
        else if (ptype === 'actionsHeader') {
            if (key === 'showActions') model.updateProps(node.id, { showActions: value });
            else if (key === 'actions') model.updateProps(node.id, { actions: String(value).split(',').map(function (s) { return s.trim(); }).filter(Boolean) });
        }
        else if (ptype === 'tb') {
            if (key === 'searchPlaceholder') model.updateProps(node.id, { searchPlaceholder: value });
            else if (key === 'searchStyle') model.updateProps(node.id, { searchStyle: value });
            else if (key === 'searchWidth') model.updateProps(node.id, { searchWidth: value });
            else if (key === 'recordInfo') model.updateProps(node.id, { recordInfo: value });
            else if (key === 'density') model.updateProps(node.id, { density: value });
            else if (key === 'exportFormats') model.updateProps(node.id, { exportFormats: csv(value) });
            else if (key === 'pageSizes') model.updateProps(node.id, { pageSizes: csv(value) });
            else if (key === 'pageSizeDefault') model.updateProps(node.id, { pageSizeDefault: value });
            else { var tc = Object.assign({}, node.props.config || {}); tc[pkey] = Object.assign({}, tc[pkey]); tc[pkey][key] = value; model.updateProps(node.id, { config: tc }); }
        }
        else if (ptype === 'action' || ptype === 'selact') { var ac = Object.assign({}, node.props.actionConfig || {}); ac[pkey] = Object.assign({}, ac[pkey]); ac[pkey][key] = value; model.updateProps(node.id, { actionConfig: ac }); }
        else if (ptype === 'button') { var bc = Object.assign({}, node.props.buttonConfig || {}); bc[pkey] = Object.assign({}, bc[pkey]); bc[pkey][key] = value; model.updateProps(node.id, { buttonConfig: bc }); }
        else if (ptype === 'item') { var items = (node.props.items || []).slice(); items[+pkey] = value; model.updateProps(node.id, { items: items }); }
        else if (ptype === 'tab') { var tabs = (node.props.tabs || []).slice(); tabs[+pkey] = value; model.updateProps(node.id, { tabs: tabs }); }
        else if (ptype === 'kpi') { var its = (node.props.items || []).map(function (x) { return Object.assign({}, x); }); if (!its[+pkey]) its[+pkey] = {}; its[+pkey][key] = value; model.updateProps(node.id, { items: its }); }
        else if (ptype === 'mdhead' || ptype === 'mdlink') {
            // Details column (Master–Detail): label/icon are per-relation,
            // everything else styles the whole column consistently.
            var mdc = Object.assign({}, node.props.detailsCol || {});
            if (ptype === 'mdlink' && (key === 'label' || key === 'icon' || key === 'showLabel')) {
                mdc.perRel = Object.assign({}, mdc.perRel);
                mdc.perRel[pkey] = Object.assign({}, mdc.perRel[pkey]);
                mdc.perRel[pkey][key] = value;
            } else if (key.indexOf('preview.') === 0) {   // preview styling → detailsCol.preview.*
                mdc.preview = Object.assign({}, mdc.preview);
                mdc.preview[key.slice(8)] = value;
            } else {
                mdc[key] = value;
            }
            model.updateProps(node.id, { detailsCol: mdc });
        }
        else if (ptype === 'dgtitle' || ptype === 'dgbadge' || ptype === 'dgadd' || ptype === 'dggo') {
            model.updateProps(node.id, kv(key, value));   // flat detail-grid props
        }
        else if (ptype === 'dgcol') {
            var dgArr = (reg.dgCols ? reg.dgCols(node.props || {}) : []).map(function (c) { return Object.assign({}, c); });
            dgArr.forEach(function (c) { if (c.field === pkey) c[key] = value; });
            model.updateProps(node.id, { columnsCfg: dgArr });
        }
        else if (ptype.indexOf('mdprev') === 0 || ptype === 'mdpreview') {
            // Preview-panel zone/component editors → detailsCol.preview.<key>;
            // per-column edits materialize the ordered column list first.
            var mdcp = Object.assign({}, node.props.detailsCol || {});
            mdcp.preview = Object.assign({}, mdcp.preview);
            if (ptype === 'mdprevcol') {
                var cseg = pkey.split(':');
                var cRelId = cseg[0], cField = cseg.slice(1).join(':');
                var cRel = (((window.PpDesignerBootstrap || {}).relations) || []).filter(function (x) { return x.id === cRelId; })[0] || {};
                var carr = (reg.mdPrevCols ? reg.mdPrevCols(mdcp.preview, cRel) : []).map(function (c) { return Object.assign({}, c); });
                carr.forEach(function (c) { if (c.field === cField) c[key] = value; });
                mdcp.preview.cols = Object.assign({}, mdcp.preview.cols);
                mdcp.preview.cols[cRelId] = carr;
            } else {
                mdcp.preview[key] = value;
            }
            model.updateProps(node.id, { detailsCol: mdcp });
        }
        else if (ptype === 'caption') { model.updateProps(node.id, { caption: value }); }
        else if (CARD_PARTS[ptype] || ptype === 'cact') {
            // Card Gallery part styling → props.partStyles[<part>][<key>]
            // (per-action buttons keep their action: partStyles['cact:view']).
            var pKey = (ptype === 'cact') ? partStr : ptype;
            var pst = Object.assign({}, node.props.partStyles || {});
            pst[pKey] = Object.assign({}, pst[pKey]); pst[pKey][key] = value;
            model.updateProps(node.id, { partStyles: pst });
        }
    }

    /* ---------- columns editor (grid) ---------- */
    // A <select> of the active page table's columns (blank = the placeholder).
    function fieldSelect(path, val, placeholder) {
        var fields = store.fieldsFor((model.activePage() || {}).table || store.ui('table')) || [];
        var cur = String(val == null ? '' : val);
        var opts = '<option value="">— ' + esc(placeholder || 'none') + ' —</option>' + fields.map(function (fld) {
            return '<option value="' + esc(fld.name) + '"' + (fld.name === cur ? ' selected' : '') + '>' + esc(fld.label || title(fld.name)) + '</option>';
        }).join('');
        return '<select data-path="' + path + '">' + opts + '</select>';
    }

    // Badge colour map editor: rows of [value → colour], plus a blank-value row
    // that sets the default colour ('*'). Mirrors the generated card badge.
    function badgeColorsEditor(node, key) {
        var map = (node.props[key] && typeof node.props[key] === 'object') ? node.props[key] : {};
        var entries = Object.keys(map).map(function (k) { return { value: k === '*' ? '' : k, color: map[k] }; });
        if (!entries.length) entries.push({ value: '', color: '#6366f1' });
        var rows = entries.map(function (e, i) {
            return '<div class="ppd-field-row ppd-bc-row" style="gap:6px">' +
                '<input class="ppd-bc-value" data-bc="' + i + '" value="' + esc(e.value) + '" placeholder="value (blank = default)" style="flex:1">' +
                '<input type="color" class="ppd-bc-color" data-bc="' + i + '" value="' + esc(e.color || '#6366f1') + '">' +
                '<button type="button" class="ppd-eyedrop" title="Eyedropper — pick a colour from anywhere on screen"><i class="bi bi-eyedropper"></i></button>' +
                '<button type="button" class="ppd-btn danger ppd-bc-del" data-bc-del="' + i + '" title="Remove"><i class="bi bi-x"></i></button>' +
                '</div>';
        }).join('');
        return '<div class="ppd-badge-colors" data-badgecolors>' + rows +
            '<button type="button" class="ppd-rule-add" data-bc-add><i class="bi bi-plus-lg"></i> Add colour</button>' +
            '<div class="ppd-note"><i class="bi bi-palette"></i> Map a field value to a badge colour. A blank value sets the default for all others.</div></div>';
    }
    // Rebuild the whole badgeColors object from the editor rows in `container`.
    function collectBadgeColors(container) {
        var out = {};
        qsa('.ppd-bc-row', container).forEach(function (row) {
            var v = (row.querySelector('.ppd-bc-value') || {}).value;
            var c = (row.querySelector('.ppd-bc-color') || {}).value;
            var k = (v == null ? '' : String(v).trim()) || '*';
            if (c) out[k] = c;
        });
        return out;
    }
    // Wire (or re-wire) the badge-colour editor: value/colour inputs commit
    // without re-rendering (keeps focus while typing); add/remove mutate the DOM.
    function wireBadgeColors(container, node) {
        var commit = function () { model.updateProps(node.id, { badgeColors: collectBadgeColors(container) }); };
        qsa('.ppd-bc-value', container).forEach(function (el) { if (!el.__bc) { el.__bc = 1; el.addEventListener('input', commit); } });
        qsa('.ppd-bc-color', container).forEach(function (el) { if (!el.__bc) { el.__bc = 1; el.addEventListener('change', commit); } });
        qsa('.ppd-bc-del', container).forEach(function (b) { if (!b.__bc) { b.__bc = 1; b.addEventListener('click', function () { var r = b.closest('.ppd-bc-row'); if (r) r.remove(); commit(); }); } });
        var add = container.querySelector('[data-bc-add]');
        if (add && !add.__bc) {
            add.__bc = 1;
            add.addEventListener('click', function () {
                var row = document.createElement('div');
                row.className = 'ppd-field-row ppd-bc-row'; row.style.gap = '6px';
                row.innerHTML = '<input class="ppd-bc-value" value="" placeholder="value (blank = default)" style="flex:1">' +
                    '<input type="color" class="ppd-bc-color" value="#16a34a">' +
                    '<button type="button" class="ppd-btn danger ppd-bc-del" title="Remove"><i class="bi bi-x"></i></button>';
                add.parentNode.insertBefore(row, add);
                wireBadgeColors(container, node);
            });
        }
    }

    function columnsEditor(node) {
        var ctxFields = store.fieldsFor((model.activePage() || {}).table || store.ui('table'));
        var cols = (node.props.columns && node.props.columns.length) ? node.props.columns
            : ctxFields.slice(0, 8).map(function (f) {
                var ctl = (reg.controlDefaultsFor ? reg.controlDefaultsFor(f) : { viewControl: '', editControl: '' });
                var col = { field: f.name, label: f.label || title(f.name), visible: true, sortable: true };
                if (ctl.viewControl) col.viewControl = ctl.viewControl;
                if (ctl.editControl) col.editControl = ctl.editControl;
                return col;
            });
        var rows = cols.map(function (c, i) {
            return '<div class="ppd-field-row"><span>' + esc(c.field) + '</span>' +
                '<span style="display:flex;gap:8px;align-items:center">' +
                '<input data-col="' + i + '" data-colkey="label" value="' + esc(c.label || '') + '" style="flex:1">' +
                '<label class="ppd-check"><input type="checkbox" data-col="' + i + '" data-colkey="visible" ' + (c.visible !== false ? 'checked' : '') + '> show</label>' +
                '</span></div>';
        }).join('');
        return '<div class="ppd-insp-group" open><summary>Columns (' + cols.length + ')</summary>' + rows + '</div>';
    }

    /* ---------- generic repeatable-list editor ----------
       Edits an array-of-objects prop (Timeline milestones, Quick Links, …). Each
       item is a small card of sub-fields; controls route through the normal
       data-path pipeline via the `list.<key>.<index>.<subkey>` prefix (see
       applyPath), so colour/icon pickers etc. all work for free. Add/remove are
       wired in bind(). Schema shape:
         { key, label, type:'list', item:[{key,label,type,options?,placeholder?}],
           itemLabel?, addLabel?, default?:{…} }                                */
    function listControl(path, s, v) {
        if (s.type === 'select') return select(path, s.options || [], v == null ? '' : String(v));
        if (s.type === 'bool' || s.type === 'check') return '<label class="ppd-check"><input type="checkbox" data-path="' + path + '"' + (v ? ' checked' : '') + '> ' + esc(s.boolLabel || '') + '</label>';
        if (s.type === 'color') return colorInput(path, v || '');
        if (s.type === 'icon') return iconPicker(path, v || '');
        if (s.type === 'textarea') {   // compact + auto-grow (grows with content, no fixed 4 rows)
            return '<textarea rows="2" class="ppd-litem-ta" data-path="' + path + '"' + (s.placeholder ? ' placeholder="' + esc(s.placeholder) + '"' : '') +
                ' oninput="this.style.height=\'auto\';this.style.height=Math.min(this.scrollHeight,220)+\'px\'">' + esc(v || '') + '</textarea>';
        }
        if (s.type === 'number') return input('number', path, v == null ? '' : v);
        return input('text', path, v == null ? '' : v, false, s.placeholder);
    }
    function listEditor(node, f) {
        var arr = Array.isArray(node.props[f.key]) ? node.props[f.key] : [];
        var subs = f.item || [{ key: 'label', label: 'Label', type: 'text' }];
        var leBoot = window.PpDesignerBootstrap || {};
        var rows = arr.map(function (it, i) {
            it = it || {};
            var body = subs.map(function (s) {
                var lp = 'list.' + f.key + '.' + i + '.' + s.key;
                // 'dbtable' — a live dropdown of every project table.
                if (s.type === 'dbtable') {
                    var curT = it[s.key] == null ? '' : String(it[s.key]);
                    var optsT = '<option value="">' + esc(s.placeholder || '— none —') + '</option>'
                        + (leBoot.lookupTables || leBoot.tables || []).map(function (t) { return '<option' + (t.name === curT ? ' selected' : '') + '>' + esc(t.name) + '</option>'; }).join('');
                    return field(s.label, '<select data-path="' + lp + '">' + optsT + '</select>', s.hint);
                }
                // 'column' — live column dropdown; source table = the outer f.tableKey,
                // OR this item's own table field when `fromItem` is set.
                if (s.type === 'column') {
                    var colTbl = s.fromItem ? String(it[s.fromItem] || '')
                        : (f.tableKey === '__page__' ? String((model.activePage() || {}).table || store.ui('table') || '') : String((node.props || {})[f.tableKey || 'table'] || ''));
                    var cur = it[s.key] == null ? '' : String(it[s.key]);
                    var opts = '<option value="">' + esc(s.placeholder || '— any field —') + '</option>'
                        + ((leBoot.fieldsByTable || {})[colTbl] || []).map(function (fl) { return '<option' + (fl.name === cur ? ' selected' : '') + '>' + esc(fl.name) + '</option>'; }).join('');
                    return field(s.label, '<select data-path="' + lp + '">' + opts + '</select>', s.hint);
                }
                return field(s.label, listControl(lp, s, it[s.key]), s.hint);
            }).join('');
            return '<div class="ppd-litem">' +
                '<div class="ppd-litem-h"><strong>' + esc((f.itemLabel || 'Item') + ' ' + (i + 1)) + '</strong>' +
                '<span class="ppd-litem-tools">' +
                (i > 0 ? '<button type="button" class="ppd-litem-mv" data-list-mv="' + esc(f.key) + '" data-idx="' + i + '" data-dir="-1" title="Move up"><i class="bi bi-chevron-up"></i></button>' : '') +
                (i < arr.length - 1 ? '<button type="button" class="ppd-litem-mv" data-list-mv="' + esc(f.key) + '" data-idx="' + i + '" data-dir="1" title="Move down"><i class="bi bi-chevron-down"></i></button>' : '') +
                '<button type="button" class="ppd-litem-del" data-list-del="' + esc(f.key) + '" data-idx="' + i + '" title="Remove"><i class="bi bi-trash"></i></button>' +
                '</span></div>' + body + '</div>';
        }).join('');
        return '<div class="ppd-list">' + (rows || '<div class="ppd-note">No items yet — add one below.</div>') +
            '<button type="button" class="ppd-btn ppd-list-add" data-list-add="' + esc(f.key) + '" style="width:100%"><i class="bi bi-plus-lg"></i> ' + esc(f.addLabel || 'Add item') + '</button></div>';
    }

    /* ---------- responsive width (span) editor ---------- */
    function spansEditor(node, key) {
        var sp = node.props[key] || { desktop: 12, tablet: 12, mobile: 12 };
        // All 12 columns of the responsive grid (fraction hint where it's clean).
        var opts = [[12, 'Full'], [11, ''], [10, '⅚'], [9, '¾'], [8, '⅔'], [7, ''], [6, '½'], [5, ''], [4, '⅓'], [3, '¼'], [2, '⅙'], [1, 'Min']];
        var active = store.ui('device') || 'desktop';
        function row(dev, ic, lbl) {
            var cur = sp[dev] != null ? sp[dev] : (dev === 'mobile' ? 12 : (sp.desktop || 12));
            var sel = '<select data-path="width.' + key + '.' + dev + '">' + opts.map(function (o) { return '<option value="' + o[0] + '"' + (o[0] === cur ? ' selected' : '') + '>' + (o[1] ? esc(o[1]) + ' · ' : '') + o[0] + '/12</option>'; }).join('') + '</select>';
            return '<div class="ppd-field-row' + (dev === active ? ' ppd-span-active' : '') + '"><span><i class="bi ' + ic + '"></i> ' + lbl + '</span>' + sel + '</div>';
        }
        // Per-device VISIBILITY — a checkbox per device (checked = shown). Unchecking
        // a device adds it to node.props.hideOn, which the build turns into a
        // `pp-hide-<device>` class + media query so the component drops out at that
        // size only. (Width above controls how wide; this controls whether at all.)
        var hidden = (node.props.hideOn || []).filter(function (x) { return ['desktop', 'tablet', 'mobile'].indexOf(x) >= 0; });
        function visBox(dev, ic, lbl) {
            var on = hidden.indexOf(dev) < 0;
            // Icon-only chip; the tooltip states the device AND the current state so
            // it's clear a highlighted chip = shown and clicking it hides it there.
            var tip = (on ? 'Shown on ' + lbl + ' — click to hide' : 'Hidden on ' + lbl + ' — click to show');
            return '<label class="ppd-vis-dev' + (on ? ' is-on' : '') + '" title="' + tip + '" aria-label="' + tip + '"><input type="checkbox" data-path="hide.' + dev + '"' + (on ? ' checked' : '') + '><i class="bi ' + ic + '"></i></label>';
        }
        var visHint = 'Pick which screen sizes show this component. A highlighted device = shown; click one to hide the component on that size only. Breakpoints: desktop ≥1024px · tablet 768–1023px · mobile <768px.';
        var visRow = '<div class="ppd-field-row ppd-vis-row"><span><i class="bi bi-eye"></i> Show on' + tipIcon(visHint) + '</span>'
            + '<span class="ppd-vis-devs">' + visBox('desktop', 'bi-display', 'Desktop') + visBox('tablet', 'bi-tablet', 'Tablet') + visBox('mobile', 'bi-phone', 'Mobile') + '</span></div>';
        return '<div class="ppd-spans">' + row('desktop', 'bi-display', 'Desktop') + row('tablet', 'bi-tablet', 'Tablet') + row('mobile', 'bi-phone', 'Mobile') + visRow +
            '<div class="ppd-note"><i class="bi bi-arrows-angle-expand"></i> Switch the device (top bar) to preview each size. Uncheck a device under <b>Show on</b> to hide this component there. Drag a field\'s right edge on canvas to resize.</div></div>';
    }

    /* ---------- conditional formatting editor (Events module) ---------- */
    // Normalise a rule (legacy single-condition → conditions[]) for display.
    function cfConds(r) {
        if (Array.isArray(r.conditions)) return r.conditions;
        if (r.condition) return [r.condition];
        return [{ field: '', op: '=', value: '', vtype: 'value' }];
    }
    function conditionalEditor(node) {
        var rules = node.props.conditionalRules || [];
        var fopts = store.fieldsFor((model.activePage() || {}).table).map(function (f) { return f.name; });
        var ops = ['=', '!=', '>', '<', '>=', '<=', 'contains', 'empty', 'not empty'];
        function rsel(i, key, opts, val) { return '<select data-rule="' + i + '" data-rkey="' + key + '">' + opts.map(function (o) { return '<option' + (String(o) === String(val) ? ' selected' : '') + '>' + esc(o) + '</option>'; }).join('') + '</select>'; }
        function rselF(i, key, val) { return '<select data-rule="' + i + '" data-rkey="' + key + '"><option value="">field…</option>' + fopts.map(function (o) { return '<option value="' + esc(o) + '"' + (o === val ? ' selected' : '') + '>' + esc(o) + '</option>'; }).join('') + '</select>'; }
        function rinp(i, key, val, ph) { return '<input data-rule="' + i + '" data-rkey="' + key + '" value="' + esc(val || '') + '" placeholder="' + esc(ph || '') + '">'; }
        function rcheck(i, key, on, label) { return '<label class="ppd-check"><input type="checkbox" data-rule="' + i + '" data-rkey="' + key + '" ' + (on ? 'checked' : '') + '> ' + esc(label) + '</label>'; }
        function rcolor(i, key, val, ph) { return '<span class="ppd-rule-color"><input type="color" data-rule="' + i + '" data-rkey="' + key + '" value="' + esc(val || '#ffffff') + '"><input data-rule="' + i + '" data-rkey="' + key + '" value="' + esc(val || '') + '" placeholder="' + esc(ph) + '"><button type="button" class="ppd-eyedrop" title="Eyedropper — pick a colour from anywhere on screen"><i class="bi bi-eyedropper"></i></button></span>'; }
        var rowsHtml = rules.map(function (r, i) {
            var s = r.style || {}, conds = cfConds(r), isCell = r.scope === 'cell', match = r.match === 'any' ? 'any' : 'all';
            var condLines = conds.map(function (c, j) {
                c = c || {};
                var joiner = j === 0 ? 'When' : (match === 'any' ? 'or' : 'and');
                var noVal = (c.op === 'empty' || c.op === 'not empty');
                var vtype = c.vtype === 'field' ? 'field' : 'value';
                var valCtl = noVal ? '' : (vtype === 'field'
                    ? rselF(i, 'conditions.' + j + '.value', c.value || '')
                    : rinp(i, 'conditions.' + j + '.value', c.value || '', 'value'));
                return '<div class="ppd-rule-line ppd-cond-line"><span class="ppd-cond-join">' + joiner + '</span>' +
                    rselF(i, 'conditions.' + j + '.field', c.field || '') +
                    rsel(i, 'conditions.' + j + '.op', ops, c.op || '=') +
                    (noVal ? '' : rsel(i, 'conditions.' + j + '.vtype', ['value', 'field'], vtype)) +
                    valCtl +
                    (conds.length > 1 ? '<button type="button" class="ppd-cond-del" data-cond-del="' + i + ':' + j + '" title="Remove condition"><i class="bi bi-x"></i></button>' : '') +
                    '</div>';
            }).join('');
            return '<div class="ppd-rule">' +
                '<div class="ppd-rule-head"><span>Rule ' + (i + 1) + '</span><button type="button" class="ppd-rule-del" data-rule-del="' + i + '" title="Remove"><i class="bi bi-trash"></i></button></div>' +
                '<div class="ppd-rule-line"><span>Apply to</span>' + rsel(i, 'scope', ['row', 'cell'], r.scope || 'row') + (isCell ? rselF(i, 'column', r.column || '') : '') +
                    (conds.length > 1 ? '<span>match</span>' + rsel(i, 'match', ['all', 'any'], match) : '') + '</div>' +
                condLines +
                '<button type="button" class="ppd-cond-add" data-cond-add="' + i + '"><i class="bi bi-plus"></i> Add condition</button>' +
                '<div class="ppd-rule-line"><span>Style</span>' + rcolor(i, 'style.background', s.background || '', 'bg') + rcolor(i, 'style.color', s.color || '', 'text') + rcheck(i, 'style.bold', s.bold, 'Bold') + rcheck(i, 'style.italic', s.italic, 'Italic') + '</div>' +
                '<div class="ppd-rule-line"><span>More</span>' + rcolor(i, 'style.border', s.border || '', 'border') + rinp(i, 'style.className', s.className || '', 'CSS class (optional)') + '</div>' +
                (isCell ? '<div class="ppd-rule-line"><span>Cell</span>' + rcheck(i, 'style.badge', s.badge, 'Badge pill') + rinp(i, 'style.icon', s.icon || '', 'icon e.g. check-circle') + '</div>' : '') +
                '</div>';
        }).join('');
        var cfGuideHtml =
            '<button type="button" class="ppd-cf-guide-btn" data-cf-guide><i class="bi bi-mortarboard-fill"></i> Guide &amp; examples</button>' +
            '<div class="ppd-cf-guide" id="ppdCfGuide" style="display:none">' +
                '<p class="ppd-cf-guide-lead">Each rule paints a <b>row</b> or a <b>cell</b> when the record matches. Combine conditions with <b>all</b> (AND) / <b>any</b> (OR), and compare a field to a value <b>or another field</b>.</p>' +
                '<table class="ppd-cf-guide-table"><thead><tr><th>Goal</th><th>Set the rule like this</th></tr></thead><tbody>' +
                    '<tr><td>Red row for overdue items</td><td>Apply to <code>row</code> · when <code>status = overdue</code> · background red</td></tr>' +
                    '<tr><td>Green “Approved” badge in a cell</td><td>Apply to <code>cell → status</code> · when <code>status = approved</code> · tick <b>Badge</b> + icon <code>check-circle</code></td></tr>' +
                    '<tr><td>Flag over-budget rows</td><td>Apply to <code>row</code> · when <code>actual &gt; budget</code> (set the value type to <code>field</code>)</td></tr>' +
                    '<tr><td>High priority <b>and</b> unassigned</td><td>match <code>all</code> · <code>priority = high</code> + <code>owner is empty</code> · Bold + border</td></tr>' +
                '</tbody></table>' +
                '<p class="ppd-cf-guide-note">Colours apply on the server as each row renders — correct on the first paint and in a PDF built from the View. A single value matches <b>exactly</b>, so “Male” never matches “Female”. Need computed logic (dynamic colours, calculations)? A field\'s <b>Server → Format</b> event can return custom HTML for that cell.</p>' +
            '</div>';
        return '<div class="ppd-rules">' + cfGuideHtml + rowsHtml +
            '<button type="button" class="ppd-rule-add" data-rule-add><i class="bi bi-plus-lg"></i> Add rule</button>' +
            '<div class="ppd-note"><i class="bi bi-lightning-charge"></i> Matching rows/cells are coloured in the generated app (row or cell, single or combined conditions, compare to a value or another field).</div></div>';
    }
    // Set a dotted path (arrays for numeric segments) on a deep-copied rule set.
    function cfSetPath(obj, path, value) {
        var parts = path.split('.');
        for (var k = 0; k < parts.length - 1; k++) {
            var seg = parts[k], nextNum = /^\d+$/.test(parts[k + 1]);
            if (obj[seg] === undefined || obj[seg] === null) obj[seg] = nextNum ? [] : {};
            obj = obj[seg];
        }
        obj[parts[parts.length - 1]] = value;
    }
    function applyRule(node, idx, path, value) {
        var rules = (node.props.conditionalRules || []).map(function (r) { return JSON.parse(JSON.stringify(r)); });
        var rule = rules[idx]; if (!rule) return;
        // Editing a condition migrates a legacy single-condition rule to the array
        // shape (preserving its other fields) before the path is written.
        if (path.indexOf('conditions.') === 0 && !Array.isArray(rule.conditions)) {
            rule.conditions = rule.condition ? [rule.condition] : [];
            delete rule.condition;
        }
        cfSetPath(rule, path, value);
        model.updateProps(node.id, { conditionalRules: rules });
    }
    function addRule(node) {
        var first = (store.fieldsFor((model.activePage() || {}).table)[0] || {}).name || '';
        var rules = (node.props.conditionalRules || []).slice();
        rules.push({ scope: 'row', column: '', match: 'all', conditions: [{ field: first, op: '=', value: '', vtype: 'value' }], style: { background: '#fff7ed', color: '', bold: false } });
        model.updateProps(node.id, { conditionalRules: rules });
        render();
    }
    function addCond(node, idx) {
        var first = (store.fieldsFor((model.activePage() || {}).table)[0] || {}).name || '';
        var rules = (node.props.conditionalRules || []).map(function (r) { return JSON.parse(JSON.stringify(r)); });
        var rule = rules[idx]; if (!rule) return;
        if (!Array.isArray(rule.conditions)) { rule.conditions = rule.condition ? [rule.condition] : []; delete rule.condition; }
        rule.conditions.push({ field: first, op: '=', value: '', vtype: 'value' });
        model.updateProps(node.id, { conditionalRules: rules });
        render();
    }
    function delCond(node, idx, cidx) {
        var rules = (node.props.conditionalRules || []).map(function (r) { return JSON.parse(JSON.stringify(r)); });
        var rule = rules[idx]; if (!rule) return;
        if (!Array.isArray(rule.conditions)) { rule.conditions = rule.condition ? [rule.condition] : []; delete rule.condition; }
        rule.conditions.splice(cidx, 1);
        if (rule.conditions.length === 0) rule.conditions.push({ field: '', op: '=', value: '', vtype: 'value' });
        model.updateProps(node.id, { conditionalRules: rules });
        render();
    }
    function delRule(node, idx) {
        var rules = (node.props.conditionalRules || []).slice(); rules.splice(idx, 1);
        model.updateProps(node.id, { conditionalRules: rules });
        render();
    }

    /* ---------- Card Gallery conditional formatting ---------- */
    // Each rule: { field, op, value, target, background, color, bold } — when the
    // record matches, the chosen card part is restyled (evaluated per-record at
    // runtime by renderCards in BuildWriter).
    function cardConditionalEditor(node) {
        var rules = node.props.cardRules || [];
        var fopts = store.fieldsFor((model.activePage() || {}).table).map(function (f) { return f.name; });
        var ops = ['=', '!=', '>', '<', '>=', '<=', 'contains', 'empty', 'not empty'];
        var targets = [['cbox', 'Whole card'], ['chead', 'Header'], ['ctitle', 'Title'], ['csub', 'Subtitle'], ['cbadge', 'Badge'], ['cbody', 'Body'], ['cfoot', 'Footer']];
        function csel(i, key, opts, val) { return '<select data-crule="' + i + '" data-crkey="' + key + '">' + opts.map(function (o) { return '<option' + (String(o) === String(val) ? ' selected' : '') + '>' + esc(o) + '</option>'; }).join('') + '</select>'; }
        function cselF(i, key, val) { return '<select data-crule="' + i + '" data-crkey="' + key + '"><option value="">field…</option>' + fopts.map(function (o) { return '<option' + (o === val ? ' selected' : '') + '>' + esc(o) + '</option>'; }).join('') + '</select>'; }
        function cselT(i, key, val) { return '<select data-crule="' + i + '" data-crkey="' + key + '">' + targets.map(function (o) { return '<option value="' + o[0] + '"' + (o[0] === (val || 'cbox') ? ' selected' : '') + '>' + esc(o[1]) + '</option>'; }).join('') + '</select>'; }
        function cinp(i, key, val, ph) { return '<input data-crule="' + i + '" data-crkey="' + key + '" value="' + esc(val == null ? '' : val) + '" placeholder="' + esc(ph || '') + '">'; }
        function ccolor(i, key, val, ph) { return '<span class="ppd-rule-color"><input type="color" data-crule="' + i + '" data-crkey="' + key + '" value="' + esc(val || '#ffffff') + '"><input data-crule="' + i + '" data-crkey="' + key + '" value="' + esc(val || '') + '" placeholder="' + esc(ph) + '"><button type="button" class="ppd-eyedrop" title="Eyedropper — pick a colour from anywhere on screen"><i class="bi bi-eyedropper"></i></button></span>'; }
        var rowsHtml = rules.map(function (r, i) {
            return '<div class="ppd-rule">' +
                '<div class="ppd-rule-head"><span>Rule ' + (i + 1) + '</span><button type="button" class="ppd-rule-del" data-crule-del="' + i + '" title="Remove"><i class="bi bi-trash"></i></button></div>' +
                '<div class="ppd-rule-line"><span>When</span>' + cselF(i, 'field', r.field || '') + csel(i, 'op', ops, r.op || '=') + cinp(i, 'value', r.value || '', 'value') + '</div>' +
                '<div class="ppd-rule-line"><span>Style</span>' + cselT(i, 'target', r.target || 'cbox') + '</div>' +
                '<div class="ppd-rule-line"><span></span>' + ccolor(i, 'background', r.background || '', 'bg') + ccolor(i, 'color', r.color || '', 'text') + '<label class="ppd-check"><input type="checkbox" data-crule="' + i + '" data-crkey="bold" ' + (r.bold ? 'checked' : '') + '> Bold</label></div>' +
                '</div>';
        }).join('');
        var cardGuideHtml =
            '<button type="button" class="ppd-cf-guide-btn" data-card-cf-guide><i class="bi bi-mortarboard-fill"></i> Guide &amp; examples</button>' +
            '<div class="ppd-cf-guide" id="ppdCardCfGuide" style="display:none">' +
                '<p class="ppd-cf-guide-lead">Each rule recolours <b>one part</b> of a card when the record matches. Add one rule per part you want to style.</p>' +
                '<table class="ppd-cf-guide-table"><thead><tr><th>Goal</th><th>Set the rule like this</th></tr></thead><tbody>' +
                    '<tr><td>Green header for approved records</td><td>When <code>status = approved</code> · Style <code>Header</code> · background green</td></tr>' +
                    '<tr><td>Red, bold title when overdue</td><td>When <code>status = overdue</code> · Style <code>Title</code> · text red + Bold</td></tr>' +
                    '<tr><td>Tint the whole card for high priority</td><td>When <code>priority = high</code> · Style <code>Whole card</code> · light background</td></tr>' +
                    '<tr><td>Gold badge for VIPs</td><td>When <code>level = VIP</code> · Style <code>Badge</code> · background gold</td></tr>' +
                '</tbody></table>' +
                '<p class="ppd-cf-guide-note">A single value matches <b>exactly</b>, so “Male” never matches “Female”. The parts are the same ones you style in the card layout — Whole card, Header, Title, Subtitle, Badge, Body, Footer.</p>' +
            '</div>';
        return '<div class="ppd-rules">' + cardGuideHtml + rowsHtml +
            '<button type="button" class="ppd-rule-add" data-crule-add><i class="bi bi-plus-lg"></i> Add rule</button>' +
            '<div class="ppd-note"><i class="bi bi-lightning-charge"></i> Per-record styling: when a card\'s field matches, the chosen part is recoloured. e.g. <i>status = approved → Header background green</i>. Add one rule per part.</div></div>';
    }
    function applyCardRule(node, idx, key, value) {
        var rules = (node.props.cardRules || []).map(function (r) { return Object.assign({}, r); });
        if (!rules[idx]) return;
        rules[idx][key] = value;
        model.updateProps(node.id, { cardRules: rules });
    }
    function addCardRule(node) {
        var first = (store.fieldsFor((model.activePage() || {}).table)[0] || {}).name || '';
        var rules = (node.props.cardRules || []).slice();
        rules.push({ field: first, op: '=', value: '', target: 'chead', background: '#dcfce7', color: '', bold: false });
        model.updateProps(node.id, { cardRules: rules });
        render();
    }
    function delCardRule(node, idx) {
        var rules = (node.props.cardRules || []).slice(); rules.splice(idx, 1);
        model.updateProps(node.id, { cardRules: rules });
        render();
    }

    /* ---------- per-field validation rules ---------- */
    function vCatalogue() { return (((window.PpDesignerBootstrap || {}).plugins || {}).validation) || []; }
    function vDef(type) { var c = vCatalogue(); for (var i = 0; i < c.length; i++) { if (c[i].id === type) return c[i]; } return null; }
    function vFlag(v) { return v === true || v === 'yes' || v === '1' || v === 'true' || v === 'on'; }
    function validationsEditor(node) {
        var cat = vCatalogue();
        var rules = node.props.validations || [];
        function typeSelect(i, sel) {
            var groups = {}, order = [];
            cat.forEach(function (d) { var g = d.group || 'General'; if (!groups[g]) { groups[g] = []; order.push(g); } groups[g].push(d); });
            var html = '<select data-vr="' + i + '" data-vrkey="type">';
            order.forEach(function (g) {
                html += '<optgroup label="' + esc(g) + '">';
                groups[g].forEach(function (d) { html += '<option value="' + esc(d.id) + '"' + (d.id === sel ? ' selected' : '') + '>' + esc(d.label) + '</option>'; });
                html += '</optgroup>';
            });
            return html + '</select>';
        }
        function paramCtrl(i, ps, val) {
            var key = 'params.' + ps.key;
            if (ps.type === 'bool') { return '<label class="ppd-vr-check"><input type="checkbox" data-vr="' + i + '" data-vrkey="' + key + '" ' + (vFlag(val) ? 'checked' : '') + '> ' + esc(ps.label) + '</label>'; }
            if (ps.type === 'select') {
                var opts = (ps.options || []).map(function (o) { return '<option' + (String(o) === String(val) ? ' selected' : '') + '>' + esc(o) + '</option>'; }).join('');
                return '<label class="ppd-vr-field"><span>' + esc(ps.label) + '</span><select data-vr="' + i + '" data-vrkey="' + key + '">' + opts + '</select></label>';
            }
            var t = ps.type === 'number' ? 'number' : 'text';
            return '<label class="ppd-vr-field"><span>' + esc(ps.label) + '</span><input type="' + t + '" data-vr="' + i + '" data-vrkey="' + key + '" value="' + esc(val == null ? '' : val) + '"' + (ps.placeholder ? ' placeholder="' + esc(ps.placeholder) + '"' : '') + '></label>';
        }
        var rowsHtml = rules.map(function (r, i) {
            var def = vDef(r.type) || {}; var params = r.params || {};
            var phtml = (def.params || []).map(function (ps) {
                var pv = params[ps.key]; if (pv == null && ps['default'] != null) pv = ps['default'];
                return paramCtrl(i, ps, pv);
            }).join('');
            return '<div class="ppd-vr">' +
                '<div class="ppd-vr-head">' + typeSelect(i, r.type) + '<button type="button" class="ppd-vr-del" data-vr-del="' + i + '" title="Remove rule"><i class="bi bi-trash"></i></button></div>' +
                (phtml ? '<div class="ppd-vr-params">' + phtml + '</div>' : '') +
                '<input class="ppd-vr-msg" data-vr="' + i + '" data-vrkey="message" value="' + esc(r.message || '') + '" placeholder="' + esc(def.message || 'Custom message (optional)') + '">' +
                '</div>';
        }).join('');
        if (!cat.length) return '<div class="ppd-note"><i class="bi bi-exclamation-triangle"></i> No validation rules are installed.</div>';
        return '<div class="ppd-vrs">' + rowsHtml +
            '<button type="button" class="ppd-vr-add" data-vr-add><i class="bi bi-plus-lg"></i> Add rule</button>' +
            '<div class="ppd-note"><i class="bi bi-shield-check"></i> Checked live in the browser and enforced again on the server.</div></div>';
    }
    function applyVRule(node, idx, path, value) {
        var rules = (node.props.validations || []).map(function (r) { return JSON.parse(JSON.stringify(r)); });
        if (!rules[idx]) return;
        var parts = path.split('.');
        if (parts[0] === 'type') { if (rules[idx].type !== value) { rules[idx].type = value; rules[idx].params = {}; } }
        else if (parts[0] === 'message') { rules[idx].message = value; }
        else if (parts[0] === 'params') { rules[idx].params = rules[idx].params || {}; rules[idx].params[parts[1]] = value; }
        model.updateProps(node.id, { validations: rules });
    }
    function addVRule(node) {
        var cat = vCatalogue(); var first = (cat[0] || {}).id || 'required';
        var rules = (node.props.validations || []).slice();
        rules.push({ type: first, params: {}, message: '' });
        model.updateProps(node.id, { validations: rules });
        render();
    }
    function delVRule(node, idx) {
        var rules = (node.props.validations || []).slice(); rules.splice(idx, 1);
        model.updateProps(node.id, { validations: rules });
        render();
    }

    /* ---------- field widgets ---------- */
    function group(t, fields) { return '<details class="ppd-insp-group" open><summary>' + esc(t) + '</summary>' + fields.join('') + '</details>'; }
    // Shared typography + box style fields for grid cells / rows (all optional;
    // stored under the part's level object and applied verbatim at build time).
    function cellStyleFields(s) {
        s = s || {};
        return [
            field('Text', colorInput('part.color', s.color || '')),
            field('Background', colorInput('part.background', s.background || '')),
            field('Font', fontSelect('part.font', s.font || '')),
            field('Font size', input('text', 'part.fontSize', s.fontSize || '', false, 'e.g. 14px')),
            field('Font weight', select('part.fontWeight', ['', 'normal', '500', '600', '700', '800'], s.fontWeight || '')),
            field('Border style', select('part.borderStyle', ['', 'solid', 'dashed', 'dotted', 'double', 'none'], s.borderStyle || '')),
            field('Border width', input('text', 'part.borderWidth', s.borderWidth || '', false, 'e.g. 1px')),
            field('Border color', colorInput('part.borderColor', s.borderColor || '')),
            field('Padding', input('text', 'part.padding', s.padding || '', false, 'e.g. 8px 12px')),
            field('CSS class', input('text', 'part.class', s['class'] || '')),
            fieldFull('Custom CSS (this part)', textarea('part.customCss', s.customCss || ''))
        ];
    }
    // A small info affordance next to a property label — hover for guidance.
    function tipIcon(hint) { return hint ? ' <i class="bi bi-info-circle ppd-tip" data-ppd-tip="' + esc(hint) + '" tabindex="0" aria-label="' + esc(hint) + '"></i>' : ''; }
    function field(label, control, hint) { return '<div class="ppd-field-row"><span>' + esc(label) + tipIcon(hint) + '</span>' + control + '</div>'; }

    /* ---------- PHP-expression props (Code Editor modal) ----------
       Properties whose value is a PHP EXPRESSION rather than plain text. The
       row stays typeable for one-liners; the ⟨/⟩ button opens a proper editor
       with a description, quick templates and line numbers. Descriptions mirror
       the build's compileDefaultExpr semantics: a valid expression, text
       quoted, with now()/today() sugar. */
    var PHP_PROPS = {
        defaultValue: {
            title: 'Default value (Add page)',
            desc: 'Assigned to the field when the Add page opens, unless the visitor types something else. '
                + 'It must be a valid PHP expression — text values have to be quoted.',
            examples: [['Number', '35'], ['Text', '"ABCDEF"'], ['Function', 'now()'],
                       ['Other fields', "$field('first_name') . ' ' . $field('last_name')"]],
            fields: true,
            // The page has not been filled in yet, so be precise about what a
            // reference can actually see here.
            fieldHint: 'Reads a value already prepared for this form — URL or master–detail prefill, or a default '
                + 'resolved earlier in the field order. What the visitor types is not available yet; '
                + 'use AutoUpdate value for that.',
            templates: [
                ['Current date + time', 'now()'],
                ['Current date', 'today()'],
                ['Fixed text', '"ABCDEF"'],
                ['Fixed number', '35'],
                ['Logged-in user id', "$_SESSION['user']['id'] ?? 0"],
                ['Logged-in username', "$_SESSION['user']['username'] ?? ''"],
                ['Look a value up in a table', "dbValue('SELECT name FROM customers WHERE id = ?', [$field('customer_id')])"]
            ]
        },
        autoUpdate: {
            title: 'AutoUpdate value (Edit page)',
            desc: 'Assigned to the field on every save, overriding whatever the form submitted — '
                + 'ideal for "last modified" stamps, "updated by" columns or values computed from other fields. '
                + 'It must be a valid PHP expression — text values have to be quoted. Leave empty to disable.',
            examples: [['Number', '35'], ['Text', '"ABCDEF"'], ['Function', 'now()'],
                       ['Other fields', "$field('first_name') . ' ' . $field('last_name')"]],
            fields: true,                       // show the "insert a field" chips
            dataAlias: true,                    // $data['col'] is in scope here too
            fieldHint: 'Reads the value just submitted for that field, or the one already stored when the form '
                + 'does not include it.',
            templates: [
                ['Current date + time', 'now()'],
                ['Current date', 'today()'],
                ['Logged-in user id', "$_SESSION['user']['id'] ?? 0"],
                ['Logged-in username', "$_SESSION['user']['username'] ?? ''"],
                ['Combine two fields', "$field('first_name') . ' ' . $field('last_name')"],
                ['Look a value up in a table', "dbValue('SELECT name FROM customers WHERE id = ?', [$field('customer_id')])"],
                ['Fixed text', '"ABCDEF"'],
                ['Fixed number', '35']
            ]
        }
    };
    // Fields of the page's table → clickable chips that insert a safe reference.
    function phpFieldNames() {
        var t = (model.activePage() || {}).table || store.ui('table');
        return (store.fieldsFor(t) || []).map(function (f) { return f.name; }).filter(Boolean);
    }

    /* The reference material lives in ONE foldable panel under the editor, so
       the modal stays about writing code. Open/closed is remembered — someone
       who knows the syntax never sees it again. */
    function guideOpen() {
        try { return localStorage.getItem('ppdCodeGuide') === '1'; } catch (e) { return false; }
    }
    function guideRow(label, code, hint) {
        return '<div class="ppd-guide-row"><span>' + esc(label) + '</span><div><code>' + esc(code) + '</code>'
             + (hint ? '<em>' + esc(hint) + '</em>' : '') + '</div></div>';
    }
    function guideHtml(cfg) {
        var secs = '';
        if (cfg.examples && cfg.examples.length) {
            secs += '<div class="ppd-guide-sec"><h5>Value examples</h5>'
                 + cfg.examples.map(function (e) { return guideRow(e[0], e[1], ''); }).join('') + '</div>';
        }
        if (cfg.fields) {
            secs += '<div class="ppd-guide-sec"><h5>Read another field on this form</h5>'
                 + guideRow('One field', "$field('last_name')", cfg.fieldHint || '')
                 + guideRow('Combined', "$field('first_name') . ' ' . $field('last_name')", '')
                 + (cfg.dataAlias ? guideRow('Raw array', "$data['last_name']", 'The submitted values, without the safe fallback.') : '')
                 + '</div>';
        }
        secs += '<div class="ppd-guide-sec"><h5>Read from the database</h5>'
             + guideRow('One value', "dbValue('SELECT name FROM customers WHERE id = ?', [$field('customer_id')])",
                        'The first column of the first matching row. Add a third argument for the fallback.')
             + guideRow('One row', "dbRow('SELECT * FROM customers WHERE id = ?', [$field('customer_id')])['name']", '')
             + guideRow('Numbered', "dbLookup('SELECT name FROM customers WHERE id = :1', $field('customer_id'))",
                        'Same as dbValue with :1, :2 … placeholders.')
             + guideRow('Count', "dbCount('SELECT 1 FROM orders WHERE customer_id = ?', [$field('customer_id')])", '')
             + '<p class="ppd-guide-warn"><i class="bi bi-shield-exclamation"></i> Put values in the parameter list '
             + '(<code>?</code> or <code>:1</code>) — never glue them into the SQL text, or a visitor could rewrite the query.</p>'
             + '</div>';
        secs += '<p class="ppd-guide-foot">Write the expression only — no <code>&lt;?php</code> tags and no trailing semicolon. '
             + 'Leave the editor empty to remove the value.</p>';
        return '<details class="ppd-code-guide"' + (guideOpen() ? ' open' : '') + '>'
             + '<summary><i class="bi bi-chevron-right chev"></i><span class="gt">Guide &amp; examples</span>'
             + '<span class="ppd-guide-tag">' + esc(cfg.title) + '</span></summary>'
             + '<div class="ppd-guide-body">' + secs + '</div></details>';
    }
    function phpInput(path, val, key) {
        return '<span class="ppd-phpin">'
            + '<input type="text" data-path="' + path + '" value="' + esc(val) + '" placeholder="PHP expression — e.g. now()" spellcheck="false">'
            + '<button type="button" class="ppd-php-open" data-phpedit="' + esc(key) + '" data-phppath="' + esc(path) + '" title="Open the Code Editor">'
            + '<i class="bi bi-code-slash"></i></button></span>';
    }
    // Modal editor. `onOk(value)` receives the edited expression.
    function openCodeEditor(key, current, onOk) {
        var cfg = PHP_PROPS[key] || { title: 'Code Editor', desc: '', examples: [], templates: [] };
        var wrap = document.createElement('div');
        wrap.className = 'ppd-modal ppd-codeedit';
        wrap.innerHTML =
            '<div class="ppd-modal-box ppd-code-box" role="dialog" aria-label="Code Editor">'
          +   '<div class="ppd-modal-head"><span><i class="bi bi-code-slash"></i> Code Editor</span>'
          +     '<button type="button" class="ppd-btn" data-cancel><i class="bi bi-x-lg"></i></button></div>'
          +   '<div class="ppd-code-body">'
          +     '<label class="ppd-code-tpl">Quick template:'
          +       '<select data-tpl><option value="">— choose —</option>'
          +         cfg.templates.map(function (t) { return '<option value="' + esc(t[1]) + '">' + esc(t[0]) + '</option>'; }).join('')
          +       '</select></label>'
          +     '<p class="ppd-code-lead">' + esc(cfg.desc) + '</p>'
          +     (cfg.fields && phpFieldNames().length
                  ? '<div class="ppd-code-fields"><span>Insert a field:</span>'
                    + phpFieldNames().map(function (n) {
                          return '<button type="button" data-insfield="' + esc(n) + '">' + esc(n) + '</button>'; }).join('')
                    + '</div>'
                  : '')
          +     '<div class="ppd-code-edit"><pre class="ppd-code-gutter">1</pre>'
          +       '<textarea spellcheck="false" data-code></textarea></div>'
          +     guideHtml(cfg)
          +   '</div>'
          +   '<div class="ppd-modal-foot"><button type="button" class="ppd-btn" data-cancel>Cancel</button>'
          +     '<button type="button" class="ppd-btn primary" data-ok><i class="bi bi-check-lg"></i> OK</button></div>'
          + '</div>';
        document.body.appendChild(wrap);
        var ta = wrap.querySelector('[data-code]');
        var gutter = wrap.querySelector('.ppd-code-gutter');
        ta.value = current || '';
        function renumber() {
            var n = ta.value.split('\n').length, out = [];
            for (var i = 1; i <= n; i++) out.push(i);
            gutter.textContent = out.join('\n');
        }
        function close() { document.removeEventListener('keydown', onKey); wrap.remove(); }
        function onKey(e) {
            if (e.key === 'Escape') { e.preventDefault(); close(); }
            // ⌘/Ctrl+Enter commits, like the other editors in the console
            else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); commit(); }
        }
        function commit() { var v = ta.value.trim(); close(); onOk(v); }
        renumber();
        ta.addEventListener('input', renumber);
        ta.addEventListener('scroll', function () { gutter.scrollTop = ta.scrollTop; });
        wrap.querySelector('[data-tpl]').addEventListener('change', function () {
            if (!this.value) return;
            ta.value = this.value; this.value = ''; renumber(); ta.focus();
        });
        // field chips insert a reference AT THE CURSOR so they compose into a
        // bigger expression (e.g. $field('first') . ' ' . $field('last'))
        qsa('[data-insfield]', wrap).forEach(function (b) {
            b.addEventListener('click', function () {
                var snip = "$field('" + b.getAttribute('data-insfield') + "')";
                var s = ta.selectionStart, e = ta.selectionEnd;
                ta.value = ta.value.slice(0, s) + snip + ta.value.slice(e);
                var caret = s + snip.length;
                renumber(); ta.focus(); ta.setSelectionRange(caret, caret);
            });
        });
        var guide = wrap.querySelector('.ppd-code-guide');
        if (guide) guide.addEventListener('toggle', function () {
            try { localStorage.setItem('ppdCodeGuide', guide.open ? '1' : '0'); } catch (e) {}
        });
        wrap.querySelector('[data-ok]').addEventListener('click', commit);
        qsa('[data-cancel]', wrap).forEach(function (b) { b.addEventListener('click', close); });
        wrap.addEventListener('mousedown', function (e) { if (e.target === wrap) close(); });
        document.addEventListener('keydown', onKey);
        setTimeout(function () { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); }, 10);
    }
    function fieldFull(label, control, hint) { return '<div class="ppd-field-row full"><span>' + esc(label) + tipIcon(hint) + '</span>' + control + '</div>'; }
    function input(type, path, val, ro, ph) { return '<input type="' + type + '"' + (path ? ' data-path="' + path + '"' : '') + ' value="' + esc(val) + '"' + (ro ? ' readonly' : '') + (ph ? ' placeholder="' + esc(ph) + '"' : '') + '>'; }
    // A range slider with a live value read-out. Writes through the normal
    // data-path pipeline (bind() listens on 'input'); the inline oninput only
    // updates the visible number, never the model.
    function rangeInput(path, val, min, max, step, suffix, def) {
        min = (min == null ? 0 : min); max = (max == null ? 100 : max); step = (step == null ? 1 : step); suffix = suffix || '';
        var v = (val === '' || val == null) ? (def == null ? min : def) : val;
        return '<span class="ppd-rangein"><input type="range" data-path="' + path + '" min="' + min + '" max="' + max + '" step="' + step + '" value="' + esc(v) + '" oninput="this.nextElementSibling.textContent=this.value+\'' + esc(suffix) + '\'"><b class="ppd-range-val">' + esc(v) + esc(suffix) + '</b></span>'; }
    function textarea(path, val) { return '<textarea rows="4" data-path="' + path + '">' + esc(val) + '</textarea>'; }
    // Options are plain strings (value === text) or `{value,label}` objects when
    // a choice needs a friendly caption for a non-obvious value (e.g. '' =
    // "Match form" for the Cell Grid's inherited gap).
    // Normalise one option into { v: stored-value, t: display-label }. Accepts a
    // plain string ('left'), an array pair (['left','Left rail']), or an object
    // ({ value:'left', label:'Left rail' }). Keeps every options-driven control
    // backward-compatible while letting authors give human labels.
    function optVL(o) {
        if (Array.isArray(o)) return { v: String(o[0] == null ? '' : o[0]), t: String(o[1] == null ? o[0] : o[1]) };
        if (o && typeof o === 'object') return { v: String(o.value == null ? '' : o.value), t: String(o.label == null ? o.value : o.label) };
        return { v: String(o), t: String(o) };
    }
    function select(path, opts, val) {
        return '<select data-path="' + path + '">' + opts.map(function (o) {
            var x = optVL(o);
            return '<option value="' + esc(x.v) + '"' + (x.v === String(val == null ? '' : val) ? ' selected' : '') + '>' + esc(x.t) + '</option>';
        }).join('') + '</select>';
    }
    // Font dropdown where each option is rendered IN its own font (loaded from
    // fonts.css), so the list doubles as a live preview. Names come from the
    // registry (already A→Z, blank first). Generic keywords stay unquoted.
    // A custom font dropdown whose rows render in their OWN font — a true live
    // preview. Native <select> can't do this: on macOS the OS draws the option
    // list and ignores per-option font-family. So we render a hidden data-path
    // input (persists through the generic bind listener, incl. `part.*` →
    // applyPartValue) plus a button + a fixed-positioned menu wired by
    // wireFontPicker(). Signature unchanged so every call site is untouched.
    function fontFamilyCss(n) {
        var generic = { 'system-ui': 1, 'monospace': 1, 'sans-serif': 1, 'serif': 1, 'cursive': 1 };
        return n === '' ? '' : (generic[n] ? n : "'" + n.replace(/['"\\]/g, '') + "'");
    }
    function fontSelect(path, val) {
        var opts = (reg && reg.fontChoices) || ['', 'Inter', 'system-ui', 'monospace'];
        var cur = String(val == null ? '' : val);
        var curLbl = cur === '' ? '— inherit —' : cur;
        var curSt = cur === '' ? '' : ' style="font-family:' + fontFamilyCss(cur) + '"';
        var rows = opts.map(function (o) {
            var v = String(o == null ? '' : o);
            var label = v === '' ? '— inherit —' : v;
            var st = v === '' ? '' : ' style="font-family:' + fontFamilyCss(v) + '"';
            return '<div class="ppd-fp-opt' + (v === cur ? ' sel' : '') + '" role="option" data-font="' + esc(v) + '"' + st + '>' +
                '<i class="bi bi-check2 ppd-fp-tick"></i><span class="ppd-fp-name">' + esc(label) + '</span></div>';
        }).join('');
        return '<div class="ppd-fp" data-fontpick>' +
            '<input type="hidden" class="ppd-fp-val" data-path="' + esc(path) + '" value="' + esc(cur) + '">' +
            '<button type="button" class="ppd-fp-trigger"' + curSt + '>' +
                '<span class="ppd-fp-cur">' + esc(curLbl) + '</span>' +
                '<i class="bi bi-chevron-down ppd-fp-caret"></i>' +
            '</button>' +
            '<div class="ppd-fp-menu" hidden>' +
                '<div class="ppd-fp-search-wrap"><i class="bi bi-search"></i>' +
                    '<input type="text" class="ppd-fp-search" placeholder="Search fonts…"></div>' +
                '<div class="ppd-fp-list" role="listbox">' + rows + '</div>' +
                '<div class="ppd-fp-empty" hidden>No fonts match</div>' +
            '</div>' +
        '</div>';
    }
    function wireFontPicker(pick) {
        var valEl = pick.querySelector('.ppd-fp-val');
        var trigger = pick.querySelector('.ppd-fp-trigger');
        var menu = pick.querySelector('.ppd-fp-menu');
        var search = pick.querySelector('.ppd-fp-search');
        var list = pick.querySelector('.ppd-fp-list');
        var empty = pick.querySelector('.ppd-fp-empty');
        var curSpan = pick.querySelector('.ppd-fp-cur');
        if (!valEl || !trigger || !menu || !list) return;
        var open = false;
        function place() {
            var r = trigger.getBoundingClientRect();
            menu.style.width = Math.max(r.width, 210) + 'px';
            menu.style.left = Math.min(r.left, window.innerWidth - menu.offsetWidth - 8) + 'px';
            var mh = menu.offsetHeight || 320;
            var below = window.innerHeight - r.bottom;
            if (below < mh + 10 && r.top > below) menu.style.top = Math.max(8, r.top - mh - 4) + 'px';
            else menu.style.top = (r.bottom + 4) + 'px';
        }
        function onDocDown(e) { if (!pick.contains(e.target) && !menu.contains(e.target)) closeMenu(); }
        function onScroll(e) { if (!menu.contains(e.target)) closeMenu(); }
        function openMenu() {
            if (open) return;
            open = true;
            menu.hidden = false;
            place();
            search.value = '';
            filter('');
            var sel = list.querySelector('.sel');
            if (sel) sel.scrollIntoView({ block: 'nearest' });
            setTimeout(function () { try { search.focus(); } catch (e) { } }, 0);
            document.addEventListener('mousedown', onDocDown, true);
            window.addEventListener('scroll', onScroll, true);
            window.addEventListener('resize', place);
        }
        function closeMenu() {
            if (!open) return;
            open = false;
            menu.hidden = true;
            document.removeEventListener('mousedown', onDocDown, true);
            window.removeEventListener('scroll', onScroll, true);
            window.removeEventListener('resize', place);
        }
        function filter(q) {
            q = String(q || '').trim().toLowerCase();
            var vis = 0;
            qsa('.ppd-fp-opt', list).forEach(function (o) {
                var hit = !q || (o.getAttribute('data-font') || '').toLowerCase().indexOf(q) > -1
                    || (o.textContent || '').toLowerCase().indexOf(q) > -1;
                o.style.display = hit ? '' : 'none';
                if (hit) vis++;
            });
            if (empty) empty.hidden = !!vis;
            if (open) place();
        }
        function choose(o) {
            var name = o.getAttribute('data-font') || '';
            valEl.value = name;
            var st = o.getAttribute('style') || '';
            if (st) trigger.setAttribute('style', st); else trigger.removeAttribute('style');
            if (curSpan) curSpan.textContent = name === '' ? '— inherit —' : name;
            qsa('.ppd-fp-opt.sel', list).forEach(function (x) { x.classList.remove('sel'); });
            o.classList.add('sel');
            closeMenu();
            valEl.dispatchEvent(new Event('input', { bubbles: true }));
        }
        trigger.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); if (open) closeMenu(); else openMenu(); });
        search.addEventListener('input', function () { filter(search.value); });
        search.addEventListener('keydown', function (e) {
            if (e.key === 'Escape') { e.preventDefault(); closeMenu(); trigger.focus(); return; }
            if (e.key === 'Enter') {
                e.preventDefault();
                var first = null;
                qsa('.ppd-fp-opt', list).some(function (o) { if (o.style.display !== 'none') { first = o; return true; } return false; });
                if (first) choose(first);
            }
        });
        list.addEventListener('click', function (e) {
            var o = e.target && e.target.closest ? e.target.closest('.ppd-fp-opt') : null;
            if (o) choose(o);
        });
    }
    function themeSelect(path, val) {
        var themes = (PPD.theme && PPD.theme.list()) || [];
        if (!themes.length) return input('text', path, val);
        return '<select data-path="' + path + '">' + themes.map(function (t) {
            return '<option value="' + esc(t.id) + '"' + (t.id === val ? ' selected' : '') + '>' + esc(t.label) + (t.custom ? ' ✎' : '') + '</option>';
        }).join('') + '</select>';
    }
    function colorInput(path, val) { return '<span class="ppd-colorin" style="display:flex;gap:6px;align-items:center"><input type="color" data-path="' + path + '" value="' + esc(val || '#ffffff') + '" style="width:34px;padding:2px"><input type="text" data-path="' + path + '" value="' + esc(val) + '" placeholder="#hex" style="flex:1"><button type="button" class="ppd-eyedrop" data-eyedrop="' + path + '" title="Eyedropper — pick a colour from anywhere on screen, including other apps"><i class="bi bi-eyedropper"></i></button><button type="button" class="ppd-color-reset" data-clear="' + path + '" title="Reset colour">&times;</button></span>'; }
    function checkRow(items) { return '<div class="ppd-checks">' + items.join('') + '</div>'; }
    function chk(path, label, on, hint) { return '<label class="ppd-check"' + (hint ? ' title="' + esc(hint) + '"' : '') + '><input type="checkbox" data-path="' + path + '" ' + (on ? 'checked' : '') + '> ' + esc(label) + tipIcon(hint) + '</label>'; }
    function idBox(text) { return '<div class="ppd-id">' + esc(text) + '</div>'; }
    // curated quick-pick set (bootstrap — renders instantly without a fetch)
    var CURATED = ['bi-star-fill', 'bi-heart-fill', 'bi-house', 'bi-person', 'bi-people', 'bi-gear', 'bi-bell', 'bi-search',
        'bi-plus-lg', 'bi-pencil', 'bi-trash', 'bi-check-lg', 'bi-x-lg', 'bi-eye', 'bi-download', 'bi-upload', 'bi-printer',
        'bi-calendar3', 'bi-clock', 'bi-envelope', 'bi-telephone', 'bi-geo-alt', 'bi-tag', 'bi-flag', 'bi-bookmark',
        'bi-folder', 'bi-file-earmark', 'bi-image', 'bi-camera', 'bi-graph-up', 'bi-bar-chart', 'bi-pie-chart',
        'bi-lightning', 'bi-shield-check', 'bi-lock', 'bi-unlock', 'bi-link-45deg', 'bi-share', 'bi-chat', 'bi-cart',
        'bi-credit-card', 'bi-cash', 'bi-truck', 'bi-box', 'bi-grid', 'bi-list', 'bi-funnel', 'bi-sliders', 'bi-globe'];
    var PACK_LABEL = { 'fontawesome-pro': 'FontAwesome Pro', 'bootstrap-icons': 'Bootstrap', 'material-symbols': 'Material' };
    function shortName(cls) { return String(cls || '').replace(/^(fa-(solid|regular|brands)|bi|material-symbols-outlined)\s+/, '').replace(/^(bi-|fa-)/, '').replace(/-/g, ' '); }
    // compact inspector control: preview + free-text + a Browse button that opens the big modal
    function iconPicker(path, val) {
        return '<div class="ppd-iconpick" data-iconpick data-path="' + esc(path) + '">' +
            '<span class="ppd-iconpick-prev">' + reg.icon(val || 'bi-square') + '</span>' +
            '<input type="text" class="ppd-iconpick-input" data-path="' + esc(path) + '" value="' + esc(val) + '" placeholder="bi-star · fa-solid fa-star">' +
            '<button type="button" class="ppd-iconpick-browse" data-iconpick-browse title="Browse 3,700+ icons">' + reg.icon('bi-grid-3x3-gap') + '</button>' +
            '</div>';
    }
    function wireIconPicker(pick, node) {
        var path = pick.getAttribute('data-path');
        var input = pick.querySelector('.ppd-iconpick-input');
        var prev = pick.querySelector('.ppd-iconpick-prev');
        var browse = pick.querySelector('[data-iconpick-browse]');
        if (input) input.addEventListener('input', function () { prev.innerHTML = reg.icon(input.value || 'bi-square'); });
        if (browse) browse.addEventListener('click', function () {
            openIconModal(input ? input.value : '', function (cls) {
                if (input) input.value = cls;
                prev.innerHTML = reg.icon(cls || 'bi-square');
                applyPath(path, cls, node);
            });
        });
    }

    /* ---------- full-screen icon browser modal ---------- */
    var icmEl = null, icmState = { onPick: null, current: '', pack: '', q: '', seq: 0, timer: null, buffer: [], pos: 0 };
    function icmRecent() { try { return JSON.parse(localStorage.getItem('ppd_icon_recent') || '[]'); } catch (e) { return []; } }
    function icmPushRecent(cls) {
        try { var r = icmRecent().filter(function (x) { return x !== cls; }); r.unshift(cls); localStorage.setItem('ppd_icon_recent', JSON.stringify(r.slice(0, 24))); } catch (e) { }
    }
    function icmCell(cls, label) {
        var active = cls === icmState.current ? ' is-active' : '';
        return '<button type="button" class="ppd-icm-ic' + active + '" data-iconval="' + esc(cls) + '" title="' + esc((label || shortName(cls)) + ' · ' + cls) + '">' +
            '<span class="ppd-icm-ic-glyph">' + reg.icon(cls) + '</span><span class="ppd-icm-ic-lbl">' + esc(label || shortName(cls)) + '</span></button>';
    }
    function buildIconModal() {
        var el = document.createElement('div');
        el.className = 'ppd-icm-backdrop';
        el.setAttribute('hidden', '');
        el.innerHTML =
            '<div class="ppd-icm" role="dialog" aria-label="Icon browser">' +
                '<header class="ppd-icm-head"><div><strong>Choose an icon</strong><small>FontAwesome Pro · Bootstrap · Material — 3,700+ icons</small></div>' +
                '<button type="button" class="ppd-icm-close" data-icm-close aria-label="Close">' + reg.icon('bi-x-lg') + '</button></header>' +
                '<div class="ppd-icm-toolbar">' +
                    '<label class="ppd-icm-search">' + reg.icon('bi-search') + '<input type="search" data-icm-search placeholder="Search icons — try “user”, “calendar”, “arrow”…"></label>' +
                    '<div class="ppd-icm-packs" data-icm-packs>' +
                        '<button type="button" class="is-active" data-pack="">All</button>' +
                        '<button type="button" data-pack="fontawesome-pro">FontAwesome</button>' +
                        '<button type="button" data-pack="bootstrap-icons">Bootstrap</button>' +
                        '<button type="button" data-pack="material-symbols">Material</button>' +
                    '</div>' +
                '</div>' +
                '<div class="ppd-icm-body" data-icm-body><div class="ppd-icm-grid" data-icm-grid></div><div class="ppd-icm-empty" data-icm-empty hidden></div></div>' +
                '<footer class="ppd-icm-foot"><span data-icm-count></span><span class="ppd-icm-esc">Esc to close</span></footer>' +
            '</div>';
        document.body.appendChild(el);

        var grid = el.querySelector('[data-icm-grid]');
        var bodyEl = el.querySelector('[data-icm-body]');
        var emptyEl = el.querySelector('[data-icm-empty]');
        var searchEl = el.querySelector('[data-icm-search]');
        var countEl = el.querySelector('[data-icm-count]');

        function close() { el.setAttribute('hidden', ''); }
        el.addEventListener('click', function (e) { if (e.target === el || e.target.closest('[data-icm-close]')) close(); });
        document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !el.hasAttribute('hidden')) close(); });
        grid.addEventListener('click', function (e) {
            var b = e.target.closest('[data-iconval]'); if (!b) return;
            var cls = b.getAttribute('data-iconval');
            icmPushRecent(cls);
            if (icmState.onPick) icmState.onPick(cls);
            close();
        });
        el.querySelector('[data-icm-packs]').addEventListener('click', function (e) {
            var b = e.target.closest('[data-pack]'); if (!b) return;
            qsa('[data-pack]', el).forEach(function (x) { x.classList.toggle('is-active', x === b); });
            icmState.pack = b.getAttribute('data-pack');
            load();
        });
        searchEl.addEventListener('input', function () { load(); });
        // infinite scroll — reveal more of the buffered icons as the user nears the bottom
        bodyEl.addEventListener('scroll', function () {
            if (bodyEl.scrollTop + bodyEl.clientHeight >= bodyEl.scrollHeight - 320) appendBatch(220);
        });

        function appendBatch(batch) {
            var end = Math.min(icmState.pos + batch, icmState.buffer.length);
            var html = '';
            for (var i = icmState.pos; i < end; i++) {
                var it = icmState.buffer[i];
                html += it.sec != null ? '<div class="ppd-icm-sec">' + esc(it.sec) + '</div>' : icmCell(it.cls, it.label);
            }
            if (html) grid.insertAdjacentHTML('beforeend', html);
            icmState.pos = end;
        }
        function setBuffer(items) {
            icmState.buffer = items; icmState.pos = 0; grid.innerHTML = '';
            var hasIcons = items.some(function (x) { return x.sec == null; });
            emptyEl.hidden = hasIcons;
            appendBatch(320);
            bodyEl.scrollTop = 0;
        }
        function load() {
            var q = searchEl.value.trim(); icmState.q = q;
            clearTimeout(icmState.timer);
            countEl.textContent = 'Loading…';
            var mine = ++icmState.seq;
            icmState.timer = setTimeout(function () {
                if (!PPD.api || !PPD.api.icons) { countEl.textContent = 'Search unavailable'; return; }
                var limit = q ? 900 : (icmState.pack ? 3000 : 1800);
                PPD.api.icons(q, icmState.pack, limit).then(function (res) {
                    if (mine !== icmState.seq) return;
                    var groups = (res && res.groups) || [];
                    var items = [], total = 0;
                    // recently-used first when just browsing
                    if (!q) { var rec = icmRecent(); if (rec.length) { items.push({ sec: 'Recently used' }); rec.forEach(function (c) { items.push({ cls: c }); }); } }
                    groups.forEach(function (g) {
                        if (!g.icons || !g.icons.length) return;
                        items.push({ sec: (PACK_LABEL[g.pack] || g.pack) + ' · ' + g.icons.length });
                        g.icons.forEach(function (ic) { items.push({ cls: ic.className || ic.name, label: ic.label }); total++; });
                    });
                    setBuffer(items);
                    if (!total && items.length === 0) { emptyEl.hidden = false; emptyEl.textContent = 'No icons' + (q ? ' match “' + q + '”' : ''); }
                    countEl.textContent = total ? (total + '+ icons') : '';
                }).catch(function () { countEl.textContent = 'Search unavailable'; });
            }, q ? 200 : 0);
        }

        el._open = function () { searchEl.value = ''; icmState.pack = ''; qsa('[data-pack]', el).forEach(function (x) { x.classList.toggle('is-active', x.getAttribute('data-pack') === ''); }); el.removeAttribute('hidden'); load(); setTimeout(function () { searchEl.focus(); }, 30); };
        return el;
    }
    function openIconModal(current, onPick) {
        icmState.onPick = onPick; icmState.current = current || ''; icmState.pack = '';
        if (!icmEl) icmEl = buildIconModal();
        icmEl._open();
    }

    // Master–Detail: guess the FK column in a detail table from the master
    // table's name (customers → customer_id / customers_id / customerId).
    function suggestFk(detailTable, masterTable) {
        var flds = ((window.PpDesignerBootstrap || {}).fieldsByTable || {})[detailTable] || [];
        if (!masterTable) return '';
        var singular = masterTable.replace(/ies$/, 'y').replace(/s$/, '');
        var cands = [singular + '_id', masterTable + '_id', singular + 'id', singular + 'Id', masterTable + 'Id'];
        for (var i = 0; i < cands.length; i++) {
            for (var j = 0; j < flds.length; j++) {
                if (String(flds[j].name).toLowerCase() === cands[i].toLowerCase()) return flds[j].name;
            }
        }
        return '';
    }

    /* ---------- Cell Grid (layout-grid) structure ops ---------- */
    // Grid-level structure summary + add/remove at the end (the per-cell editor
    // offers positional insert/delete/merge/split).
    function gridOpsField(node) {
        var p = (node && node.props) || {};
        var R = Math.max(1, parseInt(p.rows, 10) || 1), C = Math.max(1, parseInt(p.cols, 10) || 1);
        return '<div class="ppd-field-row"><span>Structure</span><div class="ppd-cellops ppd-cellops-inline">'
            + '<em class="ppd-gridsize">' + R + ' × ' + C + '</em>'
            + '<button type="button" class="ppd-btn" data-cellop="addrow" title="Add a row at the bottom"><i class="bi bi-plus-lg"></i> Row</button>'
            + '<button type="button" class="ppd-btn" data-cellop="addcol" title="Add a column at the right"><i class="bi bi-plus-lg"></i> Col</button>'
            + '<button type="button" class="ppd-btn" data-cellop="delrow-last" title="Remove the last row"><i class="bi bi-dash-lg"></i> Row</button>'
            + '<button type="button" class="ppd-btn" data-cellop="delcol-last" title="Remove the last column"><i class="bi bi-dash-lg"></i> Col</button>'
            + '</div></div>'
            + '<div class="ppd-note"><i class="bi bi-hand-index"></i> Click any cell on the canvas to select it — then style it, merge/split it, or insert rows/columns at its position.</div>';
    }

    // Run a structural op on a Cell Grid. Structural results carry a child-tab
    // remap (cell indexes depend on the column count); merges carry the absorbed
    // cell's children over to the master. One store.mutate each → one undo step.
    function runCellOp(node, op) {
        var ops = reg.gridOps; if (!ops || !node || node.type !== 'layout-grid') return;
        var p = node.props || {};
        var R = Math.max(1, parseInt(p.rows, 10) || 1), C = Math.max(1, parseInt(p.cols, 10) || 1);
        var part = String(store.ui('selectedPart') || '');
        var r = 0, c = 0;
        if (part.indexOf('cell:') === 0) { var a = part.slice(5).split(':'); r = parseInt(a[0], 10) || 0; c = parseInt(a[1], 10) || 0; }
        var cfg = (p.cells || {})[r + ':' + c] || {};
        var rs = Math.max(1, parseInt(cfg.rs, 10) || 1), cs = Math.max(1, parseInt(cfg.cs, 10) || 1);
        var res = null, merge = null, nextPart = part;
        switch (op) {
            case 'addrow':      res = ops.insertRow(p, R); break;
            case 'addcol':      res = ops.insertCol(p, C); break;
            case 'delrow-last': res = ops.deleteRow(p, R - 1); break;
            case 'delcol-last': res = ops.deleteCol(p, C - 1); break;
            case 'row-above':   res = ops.insertRow(p, r); nextPart = 'cell:' + (r + 1) + ':' + c; break;
            case 'row-below':   res = ops.insertRow(p, r + rs); break;
            case 'col-left':    res = ops.insertCol(p, c); nextPart = 'cell:' + r + ':' + (c + 1); break;
            case 'col-right':   res = ops.insertCol(p, c + cs); break;
            case 'delrow':      res = ops.deleteRow(p, r); nextPart = ''; break;
            case 'delcol':      res = ops.deleteCol(p, c); nextPart = ''; break;
            case 'merge-right': merge = ops.mergeRight(p, r, c); break;
            case 'merge-down':  merge = ops.mergeDown(p, r, c); break;
            case 'unmerge':     merge = ops.unmerge(p, r, c); break;
            case 'merge-selected':
                // ⌘/Ctrl-click multi-selection → merge the bounding rectangle
                var sel = String(store.ui('selectedCells') || '').split(',').filter(Boolean);
                if (sel.indexOf(r + ':' + c) < 0) sel.push(r + ':' + c);
                merge = ops.mergeRect ? ops.mergeRect(p, sel) : null;
                if (merge) nextPart = 'cell:' + Math.floor(merge.toTab / C) + ':' + (merge.toTab % C);
                break;
        }
        if (!res && !merge) {
            var maxR = ops.MAX_ROWS || 20, maxC = ops.MAX_COLS || 12;
            var msg;
            if (/row/.test(op) && !/del/.test(op) && R >= maxR) {
                msg = 'This grid is at its limit of ' + maxR + ' rows. Put the extra fields in a second Cell Grid.';
            } else if (/col/.test(op) && !/del/.test(op) && C >= maxC) {
                msg = 'This grid is at its limit of ' + maxC + ' columns — that is one per column of the page grid.';
            } else {
                msg = 'Cannot ' + op.replace(/-/g, ' ') + ' here — merges must be rectangular and the grid needs at least one row and column.';
            }
            if (PPD.toast) PPD.toast(msg, 'warn');
            return;
        }
        model.withNode(node.id, function (n) {
            if (res) {
                n.props.rows = res.rows; n.props.cols = res.cols; n.props.cells = res.cells;
                if (res.colWidths !== undefined) n.props.colWidths = res.colWidths;
                (n.children || []).forEach(function (ch) {
                    var t = res.remap(typeof ch.tab === 'number' ? ch.tab : 0);
                    ch.tab = t < 0 ? 0 : t;      // a deleted cell's children re-home to cell 1×1
                });
            } else {
                n.props.cells = merge.cells;
                var ft = merge.fromTabs || (merge.fromTab !== undefined ? [merge.fromTab] : []);
                if (ft.length) (n.children || []).forEach(function (ch) {
                    if (ft.indexOf(typeof ch.tab === 'number' ? ch.tab : 0) > -1) ch.tab = merge.toTab;
                });
            }
        });
        var uiPatch = {};
        if (nextPart !== part) uiPatch.selectedPart = nextPart;
        if (op === 'merge-selected') uiPatch.selectedCells = '';
        if (Object.keys(uiPatch).length) store.setUi(uiPatch);
    }

    /* ---------- bind live edits ---------- */
    function bind(host, node) {
        qsa('[data-path]', host).forEach(function (el) {
            var evt = (el.type === 'checkbox' || el.type === 'radio' || el.tagName === 'SELECT' || el.type === 'color') ? 'change' : 'input';
            el.addEventListener(evt, function () {
                var path = el.getAttribute('data-path');
                var val = el.type === 'checkbox'
                    ? (el.getAttribute('data-ppbool') ? (el.checked ? 'yes' : 'no') : el.checked)
                    : el.value;
                applyPath(path, val, node);
                // A checkbox flagged `rerender` reshapes its own inspector (e.g.
                // Menu Card: Show description / Show footer reveal or hide their
                // style groups). Checkboxes never hit the SELECT branch below, so
                // handle them here. (Text inputs are never re-rendered — that
                // would steal focus mid-keystroke.)
                if (el.type === 'checkbox' && /^cmp\.prop\./.test(path) && node) {
                    var fdefC = findFieldDef(reg.get(node.type), path.split('.')[2]);
                    if (fdefC && fdefC.rerender) { render(); return; }
                }
                // A radiogroup pill (e.g. Progress Card: Manual/Dynamic) reshapes its
                // own inspector via `when` — re-render so the gated rows appear/hide.
                if (el.type === 'radio' && /^cmp\.prop\./.test(path) && node) {
                    var fdefR = findFieldDef(reg.get(node.type), path.split('.')[2]);
                    if (fdefR && fdefR.rerender) { render(); return; }
                }
                // Per-device visibility toggle: refresh so the "Show on" chip state
                // and the canvas dimming (renderer honours hideOn) both update.
                if (el.type === 'checkbox' && /^hide\./.test(path)) { render(); return; }
                // Select-driven control/setting changes reshape the inspector
                // itself (e.g. Lookup Wizard: picking the table must populate
                // the dependent Key/Display column dropdowns; picking a plugin
                // must reveal its settings rows). Re-render only for selects —
                // rebuilding mid-keystroke would steal focus from text inputs.
                if (el.tagName === 'SELECT' && /^(fcset\.|part\.)/.test(path)) { render(); return; }
                if (el.tagName === 'SELECT' && /^cmp\.prop\./.test(path) && node) {
                    var fdef = findFieldDef(reg.get(node.type), path.split('.')[2]);
                    // Detail grid: picking the detail table auto-suggests its
                    // FK column when none is set yet.
                    if (node.type === 'detail-grid' && path === 'cmp.prop.table' && !(node.props || {}).fkColumn) {
                        var fkSug = suggestFk(String(val), (model.activePage() || {}).table || store.ui('table') || '');
                        if (fkSug) model.updateProps(node.id, { fkColumn: fkSug });
                    }
                    // control pickers reveal settings; dbtable drives dependent
                    // dbcolumn dropdowns; a fieldselect may gate a dependent field
                    // (e.g. Badge field → Badge colours); `rerender` flags a select
                    // that reshapes its own inspector (e.g. Hero visual → clock adds
                    // the clock controls) — all need a fresh inspector.
                    if (fdef && (fdef.type === 'control' || fdef.type === 'dbtable' || fdef.type === 'fieldselect' || fdef.rerender)) render();
                }
                // A list-item select that a sibling picker depends on (`fromItem`)
                // — e.g. Summary item: picking the Table must instantly repopulate
                // that item's Column dropdown. Path is list.<key>.<i>.<subkey>.
                if (el.tagName === 'SELECT' && /^list\./.test(path) && node) {
                    var lparts = path.split('.'), listDef = findFieldDef(reg.get(node.type), lparts[1]), subKey = lparts[3];
                    if (listDef && Array.isArray(listDef.item) && listDef.item.some(function (s) { return s.fromItem === subKey; })) { render(); return; }
                }
            });
        });
        wireRadioPills(host);
        // multiselect toggle chips: flip membership of the option in the prop array,
        // write via applyPath (canvas updates live), and just toggle the chip class —
        // no full re-render, so focus/scroll are preserved.
        qsa('.ppd-ms-opt', host).forEach(function (cb) {
            cb.addEventListener('change', function () {
                if (!node) return;
                var p2 = cb.getAttribute('data-ms-path'), key = p2.split('.')[2];
                var arr = Array.isArray(node.props[key]) ? node.props[key].map(String) : [];
                var v = cb.value, i = arr.indexOf(v);
                if (cb.checked && i < 0) arr.push(v); else if (!cb.checked && i > -1) arr.splice(i, 1);
                applyPath(p2, arr, node);
                var opt = cb.closest('.ppd-rg-opt'); if (opt) opt.classList.toggle('is-on', cb.checked);
            });
        });
        // dbcolumns multi-picker: add-from-dropdown + remove-chip. Reads/writes the
        // prop as an array through applyPath, then re-renders so chips refresh.
        var dbmsRead = function (p2) {
            var cur = (node && node.props) ? node.props[p2.split('.')[2]] : null;
            return Array.isArray(cur) ? cur.slice() : (cur ? String(cur).split(',').map(function (s) { return s.trim(); }).filter(Boolean) : []);
        };
        qsa('.ppd-dbcol-add', host).forEach(function (sel) {
            sel.addEventListener('change', function () {
                var col = sel.value; if (!col || !node) return;
                var p2 = sel.getAttribute('data-dbcol-path'), arr = dbmsRead(p2);
                if (arr.indexOf(col) < 0) arr.push(col);
                applyPath(p2, arr, node); render();
            });
        });
        qsa('[data-dbcol-remove]', host).forEach(function (btn) {
            btn.addEventListener('click', function () {
                if (!node) return;
                var col = btn.getAttribute('data-dbcol-remove'), p2 = btn.getAttribute('data-dbcol-path');
                applyPath(p2, dbmsRead(p2).filter(function (c) { return String(c) !== col; }), node); render();
            });
        });
        // Child/detail preview: reset all preview styling on this grid.
        var mdReset = host.querySelector('[data-md-reset]');
        if (mdReset && node) mdReset.addEventListener('click', function () {
            var dcr = Object.assign({}, node.props.detailsCol || {});
            delete dcr.preview;
            model.updateProps(node.id, { detailsCol: dcr });
            render();
        });
        // Plugin "Configure…" buttons → open the settings modal.
        qsa('.ppd-fc-configure', host).forEach(function (btn) {
            btn.addEventListener('click', function () {
                if (!node) return;
                openPluginModal(node, {
                    key: btn.getAttribute('data-fcm-key'),
                    settingsKey: btn.getAttribute('data-fcm-skey'),
                    category: btn.getAttribute('data-fcm-cat'),
                });
            });
        });
        // Tab reassignment (component inside a Tabs container) → model.setTab.
        var tabSel = host.querySelector('[data-pptab-assign]');
        if (tabSel && node) tabSel.addEventListener('change', function () { model.setTab(node.id, parseInt(tabSel.value, 10) || 0); });
        // Tabs component editor: add / remove a tab (re-renders the inspector).
        var addTab = host.querySelector('[data-tab-add]');
        if (addTab && node) addTab.addEventListener('click', function () {
            var arr = normTabs(node.props.tabs); arr.push({ label: 'Tab ' + (arr.length + 1) });
            model.updateProps(node.id, { tabs: arr }); render();
        });
        qsa('[data-tab-del]', host).forEach(function (b) {
            b.addEventListener('click', function () { removeTabAt(node, parseInt(b.getAttribute('data-tab-del'), 10)); render(); });
        });
        // Generic repeatable-list add / remove / reorder (type:'list').
        qsa('[data-list-add]', host).forEach(function (b) {
            if (!node) return;
            b.addEventListener('click', function () {
                var lk = b.getAttribute('data-list-add');
                var def = findFieldDef(reg.get(node.type), lk) || {};
                var arr = (Array.isArray(node.props[lk]) ? node.props[lk] : []).slice();
                arr.push(Object.assign({}, def.default || {}));
                model.updateProps(node.id, kv(lk, arr)); render();
            });
        });
        qsa('[data-list-del]', host).forEach(function (b) {
            if (!node) return;
            b.addEventListener('click', function () {
                var lk = b.getAttribute('data-list-del'), i = parseInt(b.getAttribute('data-idx'), 10);
                var arr = (Array.isArray(node.props[lk]) ? node.props[lk] : []).slice();
                arr.splice(i, 1); model.updateProps(node.id, kv(lk, arr)); render();
            });
        });
        qsa('[data-list-mv]', host).forEach(function (b) {
            if (!node) return;
            b.addEventListener('click', function () {
                var lk = b.getAttribute('data-list-mv'), i = parseInt(b.getAttribute('data-idx'), 10), dir = parseInt(b.getAttribute('data-dir'), 10);
                var arr = (Array.isArray(node.props[lk]) ? node.props[lk] : []).slice();
                var j = i + dir; if (j < 0 || j >= arr.length) return;
                var t = arr[i]; arr[i] = arr[j]; arr[j] = t;
                model.updateProps(node.id, kv(lk, arr)); render();
            });
        });
        // Cell Grid structure buttons (grid-level + selected-cell ops).
        qsa('[data-cellop]', host).forEach(function (b) {
            b.addEventListener('click', function () { runCellOp(node, b.getAttribute('data-cellop')); });
        });
        // PHP-expression props → Code Editor modal. Writing back through
        // applyPath keeps the normal undo/save pipeline; the visible input is
        // refreshed so the row and the model never disagree.
        qsa('[data-phpedit]', host).forEach(function (b) {
            b.addEventListener('click', function () {
                var path = b.getAttribute('data-phppath');
                var box = b.parentNode.querySelector('input[data-path]');
                openCodeEditor(b.getAttribute('data-phpedit'), box ? box.value : '', function (v) {
                    if (box) box.value = v;
                    applyPath(path, v, node);
                });
            });
        });
        // Label Editor-backed inputs (labels / placeholders / tooltips) → shared misc store.
        qsa('[data-label]', host).forEach(function (el) {
            el.addEventListener('input', function () {
                applyLabel(node, el.getAttribute('data-label'), el.getAttribute('data-lkey'), el.getAttribute('data-mirror'), el.value, el.getAttribute('data-lfield'));
            });
        });
        qsa('[data-iconpick]', host).forEach(function (pick) { wireIconPicker(pick, node); });
        qsa('[data-fontpick]', host).forEach(function (pick) { wireFontPicker(pick); });
        // colour reset buttons → clear the bound value + reflect in the two inputs
        qsa('[data-clear]', host).forEach(function (btn) {
            btn.addEventListener('click', function () {
                var path = btn.getAttribute('data-clear');
                var wrap = btn.closest('.ppd-colorin');
                if (wrap) { var tx = wrap.querySelector('input[type="text"]'); if (tx) tx.value = ''; var cl = wrap.querySelector('input[type="color"]'); if (cl) cl.value = '#ffffff'; }
                applyPath(path, '', node);
            });
        });
        // Eyedropper — sample a colour from anywhere on screen (other apps included)
        // via the EyeDropper API. Generic: it sets the sibling colour + text inputs
        // and dispatches their native events, reusing whatever listener each colour
        // control already has (data-path / data-rule / data-crule / data-bc). So it
        // works for every colour field in the panel. Hidden on Firefox/Safari.
        qsa('.ppd-eyedrop', host).forEach(function (btn) {
            if (typeof window.EyeDropper !== 'function') { btn.style.display = 'none'; return; }
            btn.addEventListener('click', function () {
                var wrap = btn.parentNode || host;
                try {
                    new window.EyeDropper().open().then(function (res) {
                        var hex = res && res.sRGBHex ? res.sRGBHex : '';
                        if (!hex) return;
                        var clr = wrap.querySelector('input[type="color"]');
                        if (clr) { clr.value = hex; clr.dispatchEvent(new Event('change', { bubbles: true })); }
                        // a paired hex text field (colorInput / rule colours) — never the
                        // badge "value to match" input (.ppd-bc-value).
                        var txt = wrap.querySelector('input:not([type="color"]):not([type="checkbox"]):not([type="radio"]):not(.ppd-bc-value)');
                        if (txt) { txt.value = hex; txt.dispatchEvent(new Event('input', { bubbles: true })); }
                    }).catch(function () { /* Esc / cancelled — leave the colour unchanged */ });
                } catch (e) { if (PPD.toast) PPD.toast('Screen colour picker is unavailable in this browser.', 'warn'); }
            });
        });
        var back = host.querySelector('[data-back]');
        if (back) back.addEventListener('click', function () { store.setUi({ selectedPart: '' }); });
        // conditional-rule controls
        qsa('[data-rule]', host).forEach(function (el) {
            var evt = (el.type === 'checkbox' || el.tagName === 'SELECT' || el.type === 'color') ? 'change' : 'input';
            el.addEventListener(evt, function () {
                var rkey = el.getAttribute('data-rkey');
                applyRule(node, parseInt(el.getAttribute('data-rule'), 10), rkey, el.type === 'checkbox' ? el.checked : el.value);
                // Re-render when a change alters which controls should show: scope
                // (row↔cell), match (and/or joiner labels), an op that hides the
                // value (empty/not empty), or the value-type (literal↔field).
                if (rkey === 'scope' || rkey === 'match' || /\.op$/.test(rkey) || /\.vtype$/.test(rkey)) render();
            });
        });
        var addBtn = host.querySelector('[data-rule-add]');
        if (addBtn) addBtn.addEventListener('click', function () { addRule(node); });
        qsa('[data-rule-del]', host).forEach(function (b) { b.addEventListener('click', function () { delRule(node, parseInt(b.getAttribute('data-rule-del'), 10)); }); });
        // In-place "Guide & examples" toggle for the conditional-formatting panel.
        var cfGuideBtn = host.querySelector('[data-cf-guide]');
        if (cfGuideBtn) cfGuideBtn.addEventListener('click', function () {
            var g = host.querySelector('#ppdCfGuide'); if (!g) return;
            var open = g.style.display === 'none'; g.style.display = open ? 'block' : 'none';
            cfGuideBtn.classList.toggle('is-on', open);
        });
        qsa('[data-cond-add]', host).forEach(function (b) { b.addEventListener('click', function () { addCond(node, parseInt(b.getAttribute('data-cond-add'), 10)); }); });
        qsa('[data-cond-del]', host).forEach(function (b) { b.addEventListener('click', function () { var p = b.getAttribute('data-cond-del').split(':'); delCond(node, parseInt(p[0], 10), parseInt(p[1], 10)); }); });
        // Card Gallery conditional-formatting controls
        qsa('[data-crule]', host).forEach(function (el) {
            var evt = (el.type === 'checkbox' || el.tagName === 'SELECT' || el.type === 'color') ? 'change' : 'input';
            el.addEventListener(evt, function () {
                var ck = el.getAttribute('data-crkey');
                applyCardRule(node, parseInt(el.getAttribute('data-crule'), 10), ck, el.type === 'checkbox' ? el.checked : el.value);
                if (ck === 'target') render();
            });
        });
        var addCBtn = host.querySelector('[data-crule-add]');
        if (addCBtn) addCBtn.addEventListener('click', function () { addCardRule(node); });
        // In-place "Guide & examples" toggle for the Card Gallery conditional panel.
        var cardGuideBtn = host.querySelector('[data-card-cf-guide]');
        if (cardGuideBtn) cardGuideBtn.addEventListener('click', function () {
            var g = host.querySelector('#ppdCardCfGuide'); if (!g) return;
            var open = g.style.display === 'none'; g.style.display = open ? 'block' : 'none';
            cardGuideBtn.classList.toggle('is-on', open);
        });
        qsa('[data-crule-del]', host).forEach(function (b) { b.addEventListener('click', function () { delCardRule(node, parseInt(b.getAttribute('data-crule-del'), 10)); }); });
        // "Conditional formatting" jump-button from a card part → select the whole
        // Card Gallery and scroll/flash its Conditional-formatting section.
        var jumpCf = host.querySelector('[data-jump-cf]');
        if (jumpCf && node) jumpCf.addEventListener('click', function () {
            store.setUi({ selectedId: node.id, selectedPart: '' });   // select the whole Card Gallery (re-renders inspector)
            // run after the inspector re-render settles; re-find the section each
            // time so a late render can't leave us scrolling a detached node.
            setTimeout(function scrollToCf() {
                var body = qs('.ppd-insp-body');
                var sec = qsa('.ppd-insp-group').filter(function (g) { return /Conditional formatting/i.test((g.querySelector('summary') || {}).textContent || ''); })[0];
                if (!body || !sec) return;
                if (sec.tagName === 'DETAILS') sec.open = true;
                body.scrollTop = Math.max(0, sec.getBoundingClientRect().top - body.getBoundingClientRect().top + body.scrollTop - 12);
                sec.classList.remove('ppd-flash'); void sec.offsetWidth; sec.classList.add('ppd-flash');
            }, 40);
        });
        // per-field validation-rule controls
        qsa('[data-vr]', host).forEach(function (el) {
            var evt = (el.type === 'checkbox' || el.tagName === 'SELECT') ? 'change' : 'input';
            el.addEventListener(evt, function () {
                var rkey = el.getAttribute('data-vrkey');
                applyVRule(node, parseInt(el.getAttribute('data-vr'), 10), rkey, el.type === 'checkbox' ? el.checked : el.value);
                if (rkey === 'type') render();
            });
        });
        var vAddBtn = host.querySelector('[data-vr-add]');
        if (vAddBtn) vAddBtn.addEventListener('click', function () { addVRule(node); });
        qsa('[data-vr-del]', host).forEach(function (b) { b.addEventListener('click', function () { delVRule(node, parseInt(b.getAttribute('data-vr-del'), 10)); }); });
        qsa('[data-col]', host).forEach(function (el) {
            el.addEventListener('change', function () { applyColumn(node, parseInt(el.getAttribute('data-col'), 10), el.getAttribute('data-colkey'), el.type === 'checkbox' ? el.checked : el.value); });
            el.addEventListener('input', function () { if (el.type !== 'checkbox') applyColumn(node, parseInt(el.getAttribute('data-col'), 10), el.getAttribute('data-colkey'), el.value); });
        });
        // badge-colour map editor (card-grid)
        var bcHost = host.querySelector('[data-badgecolors]');
        if (bcHost && node) wireBadgeColors(bcHost, node);
        var act = host.querySelector('.ppd-insp-actions');
        if (act && node) act.addEventListener('click', function (e) {
            var b = e.target.closest('[data-act]'); if (!b) return;
            var a = b.getAttribute('data-act');
            if (a === 'del') { model.remove(node.id); store.setUi({ selectedId: '' }); }
            else if (a === 'dup') { var id = model.duplicate(node.id); store.setUi({ selectedId: id }); }
            else if (a === 'save') { store.emit('request:save'); }
        });
    }

    // Write a Label Editor value (label / placeholder / tooltip) to the shared
    // misc store, and mirror it onto the design so the canvas stays in sync:
    //   '@node.label' → the node's own label (form field title)
    //   '@col.label'  → the grid column's label (needs data-lfield on the input)
    //   else          → a node prop (e.g. 'placeholder')
    function applyLabel(node, section, lkey, mirror, value, lfield) {
        store.labelSet(section, lkey, value);
        if (!mirror) return;
        // Page title has no node (page-level inspector) — mirror onto the active page.
        if (mirror === '@page.title') { model.updatePage({ title: value }); return; }
        if (!node) return;
        if (mirror === '@node.label') { model.updateNode(node.id, { label: value }); }
        else if (mirror === '@col.label' && lfield) { applyPartValue(node, 'head:' + lfield, 'label', value); }
        else { model.updateProps(node.id, kv(mirror, value)); }
    }

    function applyPath(path, value, node) {
        var parts = path.split('.');
        if (parts[0] === 'part') { if (node) applyPartValue(node, store.ui('selectedPart'), parts.slice(1).join('.'), value); return; }
        if (parts[0] === 'ui') {   // designer UI state (e.g. dark-mode preview)
            store.setUi(kv(parts[1], value));
            if (parts[1] === 'themeDark' && PPD.theme) {
                var pg = model.activePage();
                PPD.theme.apply((pg && pg.layout && pg.layout.theme) || PPD.theme.defaultId(), value);
            }
            render();
            return;
        }
        if (parts[0] === 'fcset') {   // field-control plugin setting: fcset.<settingsKey>.<key>
            if (!node) return;
            var sk = parts[1], skey = parts.slice(2).join('.');
            var bag = Object.assign({}, node.props[sk] || {});
            bag[skey] = value;
            // Changing a driver setting (e.g. the Lookup table) invalidates the
            // column picks that depend on it — clear them so a stale column
            // from the previous table can never persist into the build.
            var fcMap = { editSettings: ['editControl', 'edit'], viewSettings: ['viewControl', 'view'], filterSettings: ['filterControl', 'filter'] };
            var fcm = fcMap[sk];
            if (fcm) {
                var fcPlugins = (window.PpDesignerBootstrap && window.PpDesignerBootstrap.plugins && window.PpDesignerBootstrap.plugins[fcm[1]]) || [];
                var fcPl = fcPlugins.filter(function (p) { return p.id === (node.props[fcm[0]] || ''); })[0];
                ((fcPl && fcPl.settings) || []).forEach(function (s) {
                    if (s.type === 'column' && (s.tableKey || 'table') === skey) bag[s.key] = '';
                });
            }
            model.updateProps(node.id, kv(sk, bag));
            // "Apply to all pages": mirror the control config onto the same
            // field on every page of this table.
            if (node.props.fcApplyAll && model.propagateFieldControl) model.propagateFieldControl(node);
            return;
        }
        if (parts[0] === 'width') {   // responsive span: width.<propKey>.<device>
            if (!node) return;
            var wk = parts[1], dev = parts[2];
            var cur = Object.assign({}, node.props[wk] || {});
            cur[dev] = parseInt(value, 10) || 12;
            model.updateProps(node.id, kv(wk, cur));
            return;
        }
        if (parts[0] === 'list') {   // repeatable-list item field: list.<key>.<index>.<subkey>
            if (!node) return;
            var lk = parts[1], li = parseInt(parts[2], 10), lsub = parts.slice(3).join('.');
            var larr = (Array.isArray(node.props[lk]) ? node.props[lk] : []).map(function (x) { return Object.assign({}, x); });
            if (!larr[li]) return;
            larr[li][lsub] = value;
            model.updateProps(node.id, kv(lk, larr));
            return;
        }
        if (parts[0] === 'hide') {   // per-device visibility: hide.<device> (checkbox = VISIBLE)
            if (!node) return;
            var hdev = parts[1];
            if (['desktop', 'tablet', 'mobile'].indexOf(hdev) < 0) return;
            var hlist = (node.props.hideOn || []).filter(function (x) { return ['desktop', 'tablet', 'mobile'].indexOf(x) >= 0; });
            var visible = value === true || value === 'true' || value === 'yes';
            var hi = hlist.indexOf(hdev);
            if (visible) { if (hi >= 0) hlist.splice(hi, 1); }
            else if (hi < 0) { hlist.push(hdev); }
            model.updateProps(node.id, { hideOn: hlist });
            return;
        }
        if (parts[0] === 'tab') {   // per-tab config on a Tabs component: tab.<index>.<key>
            if (!node) return;
            var ti = parseInt(parts[1], 10);
            var tk = parts.slice(2).join('.');
            var tarr = (node.props.tabs && node.props.tabs.length ? node.props.tabs : ['Tab 1']).map(function (t) {
                return (t && typeof t === 'object') ? Object.assign({}, t) : { label: String(t == null ? '' : t) };
            });
            while (tarr.length <= ti) tarr.push({ label: 'Tab ' + (tarr.length + 1) });
            if (value === '' || value == null) delete tarr[ti][tk]; else tarr[ti][tk] = value;
            model.updateProps(node.id, { tabs: tarr });
            return;
        }
        if (parts[0] === 'tset') {   // list-page setting → shared misc Table Settings store
            var tpage = model.activePage();
            var ttable = (tpage && tpage.table) || store.ui('table') || '';
            if (!ttable) return;
            var tkey = parts[1];
            var tval = value;
            if (tkey === 'recordsPerPage') tval = value === '' ? '' : String(parseInt(value, 10) || 0);
            store.setTableSetting(ttable, kv(tkey, tval));
            return;
        }
        if (parts[0] === 'lset') {   // list behaviour → shared pages.json settings.list (freeze/views/group/kanban)
            var lpage = model.activePage();
            var ltable = (lpage && lpage.table) || store.ui('table') || '';
            if (!ltable) return;
            store.setListSetting(ltable, kv(parts[1], value));
            return;
        }
        if (parts[0] === 'relcol') {   // relation-section column → relationStyles[relId].cols[]
            var cpage = model.activePage(); if (!cpage) return;
            var crid = String(store.ui('selectedId') || '').replace(/^rel:/, '');
            if (!crid) return;
            var crel = (((window.PpDesignerBootstrap || {}).relations) || []).filter(function (r) { return r.id === crid; })[0] || {};
            var callRs = Object.assign({}, cpage.relationStyles || {});
            var crs = Object.assign({}, callRs[crid]);
            var cfield = parts[1], ckey = parts.slice(2).join('.');
            var carr = (reg.relCols ? reg.relCols(crs, crel) : []).map(function (c) { return Object.assign({}, c); });
            carr.forEach(function (c) { if (c.field === cfield) c[ckey] = value; });
            crs.cols = carr;
            callRs[crid] = crs;
            model.updatePage({ relationStyles: callRs });
            return;
        }
        if (parts[0] === 'relstyle') {   // Master–Detail section styling → page.relationStyles[relId].*
            var rpage = model.activePage(); if (!rpage) return;
            var rid = String(store.ui('selectedId') || '').replace(/^rel:/, '');
            if (!rid) return;
            var allRs = Object.assign({}, rpage.relationStyles || {});
            allRs[rid] = Object.assign({}, allRs[rid]);
            allRs[rid][parts.slice(1).join('.')] = value;
            model.updatePage({ relationStyles: allRs });
            return;
        }
        if (parts[0] === 'page') {
            var page = model.activePage(); if (!page) return;
            if (parts[1] === 'layout' && parts[2] === 'paper') {
                var paper = Object.assign({}, (page.layout || {}).paper || {});
                paper[parts[3]] = value;
                model.updatePage({ layout: kv('paper', paper) });
            }
            else if (parts[1] === 'layout') model.updatePage({ layout: kv(parts[2], value) });
            else if (parts[1] === 'settings') model.updatePage({ settings: kv(parts[2], value) });
            else model.updatePage(kv(parts[1], value));
            return;
        }
        if (!node) return;
        var target = parts[1], key = parts[2];
        if (target === 'style') model.updateNode(node.id, { style: kv(key, value) });
        else if (target === 'visibility') model.updateNode(node.id, { visibility: kv(key, value) });
        else if (target === 'node') model.updateNode(node.id, kv(key, value));
        else { // prop — coerce csv/number/bool
            var coerced = value;
            var d = reg.get(node.type); var fdef = findFieldDef(d, key);
            if (fdef && fdef.type === 'csv') coerced = String(value).split(',').map(function (s) { return s.trim(); }).filter(Boolean);
            else if (fdef && fdef.type === 'number') coerced = value === '' ? '' : Number(value);
            model.updateProps(node.id, kv(key, coerced));
            // "Apply to all pages" for field controls: turning the toggle ON,
            // or changing a control while it is on, mirrors the config to the
            // same field on every page of this table.
            var fcKeys = ['editControl', 'viewControl', 'fcApplyAll'];
            if (fcKeys.indexOf(key) > -1 && node.props.fcApplyAll && model.propagateFieldControl) {
                model.propagateFieldControl(node);
            }
        }
    }
    function findFieldDef(d, key) {
        var found = null;
        (d.schema ? d.schema({ props: {}, style: {}, visibility: {} }) : []).forEach(function (g) {
            g.fields.forEach(function (f) { if (f.key === key) found = f; });
        });
        return found;
    }
    function applyColumn(node, idx, key, value) {
        var cols = (node.props.columns && node.props.columns.length) ? node.props.columns.slice()
            : store.fieldsFor((model.activePage() || {}).table).slice(0, 8).map(function (f) { return { field: f.name, label: f.label || title(f.name), visible: true, sortable: true }; });
        if (!cols[idx]) return;
        cols[idx] = Object.assign({}, cols[idx]); cols[idx][key] = value;
        model.updateProps(node.id, { columns: cols });
    }
    function kv(k, v) { var o = {}; o[k] = v; return o; }
    function csv(v) { return String(v).split(',').map(function (s) { return s.trim(); }).filter(Boolean); }

    // ── Rapid property tooltips ────────────────────────────────────────
    // Instant, body-level floating tooltip for any [data-ppd-tip] (the ? / info
    // icons in the properties panel). Fixed-positioned + viewport-clamped so it
    // never clips inside the scrolling inspector, and shows with no native delay.
    (function () {
        if (window.__ppdTipInit) return; window.__ppdTipInit = 1;
        var tip = null;
        function ensure() {
            if (!tip) { tip = document.createElement('div'); tip.className = 'ppd-float-tip'; tip.setAttribute('role', 'tooltip'); document.body.appendChild(tip); }
            return tip;
        }
        function show(el) {
            var text = el.getAttribute('data-ppd-tip'); if (!text) return;
            var t = ensure(); t.textContent = text; t.style.display = 'block'; t.style.opacity = '0';
            var r = el.getBoundingClientRect(), tw = t.offsetWidth, th = t.offsetHeight, pad = 8;
            var left = Math.min(Math.max(pad, r.right - tw), window.innerWidth - tw - pad);
            var top = r.top - th - 8;
            if (top < pad) top = r.bottom + 8;                     // flip below if no room above
            t.style.left = Math.round(left) + 'px'; t.style.top = Math.round(top) + 'px'; t.style.opacity = '1';
        }
        function hide() { if (tip) { tip.style.display = 'none'; } }
        document.addEventListener('mouseover', function (e) { var el = e.target.closest && e.target.closest('[data-ppd-tip]'); if (el) show(el); });
        document.addEventListener('mouseout',  function (e) { var el = e.target.closest && e.target.closest('[data-ppd-tip]'); if (el) hide(); });
        document.addEventListener('focusin',   function (e) { var el = e.target.closest && e.target.closest('[data-ppd-tip]'); if (el) show(el); });
        document.addEventListener('focusout',  function (e) { var el = e.target.closest && e.target.closest('[data-ppd-tip]'); if (el) hide(); });
        window.addEventListener('scroll', hide, true);
    })();

    PPD.inspector = { render: render, runCellOp: runCellOp, openFieldControls: openFieldControls };
})();
