/* ============================================================
   PPphp Designer — Canvas renderer
   Renders the active page (from the model) into #ppdCanvas.
   Every component is a selectable .ppd-node with tools.
   Supports: selection, inline rename, drag/drop into the page
   AND into nested containers (sections/forms/rows), plus
   field-aware drops (DB field -> typed, bound input).
   ============================================================ */
(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 activeCtx = null;
    // paper dimensions in mm [width, height] (portrait)
    var PAPER = { A4: [210, 297], Letter: [216, 279], Legal: [216, 356], A5: [148, 210], A3: [297, 420] };
    // Generic placeholder columns shown when there is no bound table (e.g. the
    // Default List Page Template). A template is table-agnostic, so we show
    // neutral "Field 1…N" rather than fabricated column names — each real table
    // substitutes its own fields when the template is applied.
    var SAMPLE_FIELDS = [
        { name: 'field1', label: 'Field 1', type: 'varchar' },
        { name: 'field2', label: 'Field 2', type: 'varchar' },
        { name: 'field3', label: 'Field 3', type: 'varchar' },
        { name: 'field4', label: 'Field 4', type: 'varchar' },
        { name: 'field5', label: 'Field 5', type: 'varchar' }
    ];

    // Which tab a child belongs to (0-based). Untagged children default to tab 0.
    var tabState = {};   // ephemeral active-tab index per tabs-node id
    var foldState = {};  // ephemeral canvas fold preview per section-node id
    function tabOf(n) { return (n && typeof n.tab === 'number' && n.tab >= 0) ? n.tab : 0; }
    function tabsOf(node) { return (node.props && node.props.tabs && node.props.tabs.length) ? node.props.tabs : ['Tab 1']; }
    // A tab entry is either a plain label string (legacy) or a rich object
    // { label, icon, bg, color, padding }. Normalise to the object form.
    function normTab(t) { return (t && typeof t === 'object') ? t : { label: (t == null ? '' : String(t)) }; }
    function tabPaneStyle(d) {
        var s = '';
        if (d.bg) s += 'background:' + d.bg + ';';
        if (d.color) s += 'color:' + d.color + ';';
        if (d.padding) s += 'padding:' + d.padding + ';';
        return s;
    }
    function activeTabOf(node) {
        var a = tabState[node.id];
        var n = tabsOf(node).length;
        if (typeof a === 'number' && a >= 0 && a < n) return a;
        var def = parseInt((node.props || {}).activeTab, 10) || 0;   // default active tab
        return (def >= 0 && def < n) ? def : 0;
    }

    // Section fold preview (canvas): current collapsed state for a section node —
    // an ephemeral override (chevron click) defaulting to its `closedInitially`.
    function sectionFoldOf(node) {
        var f = foldState[node.id];
        return (f === true || f === false) ? f : !!((node.props || {}).closedInitially);
    }

    function ctxFor(table) {
        var fields = store.fieldsFor(table);
        if (!fields || !fields.length) fields = SAMPLE_FIELDS;
        return {
            fields: fields,
            table: table,
            sectionFold: sectionFoldOf,
            whereTabs: (store.whereTabsFor ? store.whereTabsFor(table) : []),   // Query → Additional WHERE, for the Filter Tabs preview
            // children are rendered as selectable nodes inside a droppable zone
            renderChildren: function (node, extraClass) {
                var inner = (node.children || []).map(renderNode).join('');
                return '<div class="ppd-children-zone ' + (extraClass || '') + '" data-ppd-zone="' + esc(node.id) + '" data-ppd-empty="Drop components here">' + inner + '</div>';
            },
            // Named zones (header / content / footer) for multi-slot containers.
            // Reuses the tab index that Tabs already uses, so the existing drop
            // handlers assign children to the right zone with no new plumbing.
            renderZone: function (node, zone, extraClass, placeholder) {
                // No explicit tab ⇒ CONTENT (1), not header. Children authored
                // before zones existed must keep rendering in the body.
                var kids = (node.children || []).filter(function (ch) {
                    var t = (ch && typeof ch.tab === 'number' && ch.tab >= 0) ? ch.tab : 1;
                    return t === zone;
                });
                return '<div class="ppd-children-zone ' + (extraClass || '') + '"' +
                    ' data-ppd-zone="' + esc(node.id) + '" data-ppd-tab="' + zone + '"' +
                    ' data-ppd-empty="' + esc(placeholder || 'Drop components here') + '">' +
                    kids.map(renderNode).join('') + '</div>';
            },
            // Tabs: a clickable bar + one droppable zone PER tab (WYSIWYG — mirrors
            // the generated app). Each child is filtered into its tab; orphans
            // (tab index beyond the label count) fall into the last tab.
            renderTabbed: function (node) {
                var tabs = tabsOf(node).map(normTab);
                var active = activeTabOf(node);
                var last = tabs.length - 1;
                var bar = '<div class="ppd-tabbar">' + tabs.map(function (d, i) {
                    var lbl = d.label || ('Tab ' + (i + 1));
                    var ic = d.icon ? reg.icon(d.icon) + ' ' : '';
                    return '<button type="button" class="ppd-tab' + (i === active ? ' active' : '') + '" data-pptab-canvas="' + esc(node.id) + '|' + i + '">' + ic + esc(lbl) + '</button>';
                }).join('') + '</div>';
                var panes = tabs.map(function (d, i) {
                    var lbl = d.label || ('Tab ' + (i + 1));
                    var kids = (node.children || []).filter(function (ch) { var ct = tabOf(ch); return ct === i || (i === last && ct > last); });
                    var inner = kids.map(renderNode).join('');
                    var st = tabPaneStyle(d);
                    return '<div class="ppd-children-zone ppd-tabpane' + (i === active ? ' active' : '') + '" data-ppd-zone="' + esc(node.id) + '" data-ppd-tab="' + i + '"' + (i === active ? '' : ' hidden') + (st ? ' style="' + esc(st) + '"' : '') + ' data-ppd-empty="Drop components in “' + esc(lbl) + '”">' + inner + '</div>';
                }).join('');
                return bar + panes;
            }
        };
    }

    // Style-panel bag → inline CSS. Keep in lockstep with BuildWriter::nodeStyleCss.
    var STYLE_MAP = [
        ['color', 'color'], ['background', 'background'], ['font', 'font-family'],
        ['fontSize', 'font-size'], ['fontWeight', 'font-weight'], ['align', 'text-align'],
        ['padding', 'padding'], ['margin', 'margin'], ['width', 'width'],
        ['minHeight', 'min-height'], ['opacity', 'opacity'],
        ['border', 'border'], ['borderRadius', 'border-radius'], ['shadow', 'box-shadow']
    ];
    function styleCss(style) {
        var css = '';
        for (var i = 0; i < STYLE_MAP.length; i++) {
            var v = style[STYLE_MAP[i][0]];
            if (v != null && String(v) !== '') css += STYLE_MAP[i][1] + ':' + v + ';';
        }
        return css;
    }
    // Per-component scoped Custom CSS — mirrors BuildWriter::customCssBlock so the
    // canvas preview matches the generated app. `&` = this element; else declarations.
    function scopeClass(id) { return 'ppcss-' + String(id == null ? '' : id).replace(/[^a-zA-Z0-9_-]/g, '-'); }
    function customCssBlock(id, css) {
        css = String(css).replace(/</g, '');
        var scope = '.' + scopeClass(id);
        var rules = '';
        var base = css.replace(/&[^{]*\{[^}]*\}/g, function (m) { rules += m.replace(/&/g, scope); return ''; }).trim();
        var out = (base ? scope + '{' + base + '}' : '') + rules;
        return out ? '<style>' + out + '</style>' : '';
    }

    function renderNode(node) {
        var d = reg.get(node.type);
        var inner;
        try { inner = d.render(node, activeCtx); } catch (e) { inner = '<div class="ppd-c-text">⚠ ' + esc(node.type) + '</div>'; }
        var selected = store.ui('selectedId') === node.id && !store.ui('selectedPart');
        var style = node.style || {};
        // Full Style-panel mapping — MUST mirror BuildWriter::nodeStyleCss so the
        // canvas and the generated app look identical.
        var css = styleCss(style);
        // Responsive column span (any component inside a 12-col grid). MUST mirror
        // BuildWriter::wrapComponent: per-device spans apply ONLY when the component
        // is narrowed on desktop (span < 12). A full-width-on-desktop block stays
        // full width on every device — its own layout handles responsiveness. This
        // is what keeps a full-bleed Cell Grid from shrinking to 6/12 on mobile
        // (which squeezed its cells until their cards overlapped in the preview).
        if (node.props && node.props.spans) {
            var dsk = Math.min(12, parseInt(node.props.spans.desktop, 10) || 12);
            if (dsk < 12) {
                var dev = store.ui('device') || 'desktop';
                var sp = node.props.spans[dev] || dsk;
                // Menu cards are capped at a minimum span of 2 → at most 6 per row.
                var minSp = node.type === 'menu-card' ? 2 : 1;
                css += 'grid-column: span ' + Math.max(minSp, Math.min(12, sp)) + ';';
            }
        }
        var hidden = node.visibility && node.visibility.visible === false;
        if (hidden) css += 'opacity:.45;';
        // Per-device visibility (props.hideOn): when the current preview device is
        // one this component hides on, show it faded with a "hidden here" marker so
        // the designer sees what the built page will drop at that size.
        var devNow = store.ui('device') || 'desktop';
        var hideDev = node.props && Array.isArray(node.props.hideOn) && node.props.hideOn.indexOf(devNow) >= 0;
        if (hideDev && !hidden) css += 'opacity:.4;';
        var isContainer = !!d.container;
        var custom = style.customCss ? String(style.customCss) : '';
        // Scroll-reveal ("Animate in"): on the canvas we add the class WITHOUT the
        // is-armed state (no observer runs here) so the component shows normally;
        // the build arms + reveals it on scroll. Mirrors BuildWriter::wrapComponent.
        var revealCls = '';
        if (['fade', 'up', 'down', 'left', 'right', 'zoom'].indexOf(String(style.reveal)) > -1) {
            revealCls = ' ppd-c-reveal rv-' + esc(String(style.reveal));
            if (['1', '2', '3', '4'].indexOf(String(style.revealDelay)) > -1) revealCls += ' rd-' + esc(String(style.revealDelay));
        }
        var extraClass = (style.class ? ' ' + esc(String(style.class)) : '') + (custom ? ' ' + scopeClass(node.id) : '') + revealCls;
        return '<div class="ppd-node ppd-type-' + esc(String(node.type || 'component').replace(/[^a-z0-9_-]/gi, '-')) + (selected ? ' selected' : '') + (isContainer ? ' is-container' : '') + (hideDev ? ' ppd-hide-dev' : '') + extraClass + '"' +
            ' data-ppd-id="' + esc(node.id) + '" data-label="' + esc(node.label || node.type) + '" draggable="true"' +
            (css ? ' style="' + esc(css) + '"' : '') + '>' +
            (custom ? customCssBlock(node.id, custom) : '') +
            '<div class="ppd-node-tools">' +
                '<span class="ppd-node-type">' + reg.icon(d.icon) + '</span>' +
                // A field node gets a one-click Field-controls button (Edit / View /
                // Filter / Validation) — the reliable way to open the studio on
                // Add/Edit/View forms (double-click is fragile inside grid cells).
                ((node.type === 'field' && node.props && node.props.field)
                    ? '<button class="fc" data-act="fc" data-fc-field="' + esc(String(node.props.field)) + '" data-fc-label="' + esc(String(node.props.label || node.label || node.props.field)) + '" title="Field controls — Edit · View · Filter · Validation"><i class="bi bi-sliders2"></i></button>'
                    : '') +
                '<button class="dup" data-act="dup" title="Duplicate"><i class="bi bi-files"></i></button>' +
                '<button class="del" data-act="del" title="Delete"><i class="bi bi-trash"></i></button>' +
            '</div>' + inner + '</div>';
    }

    /* ---- Master–Detail relation sections on view/edit canvas ----
       Auto from Tables → Relations (For Master Table → View/Edit, inline mode).
       Not model components — a page-level overlay whose styling is stored in
       page.relationStyles[relId] and applied by the build's relationGridsHtml. */
    function viewEditRels(page, pageType) {
        var kb = window.PpDesignerBootstrap || {};
        var t = page.table || store.ui('table') || '';   // active table lives in UI state
        return ((kb.relations) || []).filter(function (r) {
            return r.master_table === t &&
                (r.master_pages || []).indexOf(pageType) > -1 &&
                (r.display_option || 'inline') === 'inline';
        }).sort(function (a, b) { return (a.display_order || 0) - (b.display_order || 0); });
    }
    function relSectionHtml(page, r) {
        var kb = window.PpDesignerBootstrap || {};
        var st = ((page.relationStyles || {})[r.id]) || {};
        var relId = 'rel:' + r.id;
        var pt = function (part, cls) {   // part attrs on the synthetic rel id
            return 'data-ppd-id="' + esc(relId) + '" data-ppd-part="' + esc(part) + '" class="ppd-part ' + esc(cls || '') + '"';
        };
        var cols = reg.relCols(st, r);
        var selected = store.ui('selectedId') === relId && !store.ui('selectedPart');
        var secCss = 'border:1px solid ' + (st.border || 'var(--ppd-border,#e2e8f0)') + ';border-radius:' + (st.radius || '12px') +
            ';background:' + (st.bg || 'var(--ppd-surface,#fff)') + ';padding:' + (st.pad || '14px') + ';';
        var headCss = 'display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px;' +
            (st.headerBg ? 'background:' + st.headerBg + ';border-radius:8px;padding:6px 10px;margin:-4px -4px 8px;' : '') +
            (st.headerColor ? 'color:' + st.headerColor + ';' : '');
        var cellPad = st.compact ? '2px 8px' : '5px 8px';
        // per-column selectable + draggable headers (hidden = dimmed)
        var head = cols.map(function (c) {
            var thc = 'text-align:' + (c.align || 'left') + ';padding:' + cellPad + ';border-bottom:1px solid var(--ppd-border,#e2e8f0);color:#64748b;font-weight:600;' +
                (c.width ? 'width:' + c.width + ';' : '');
            return '<th ' + pt('rscol:' + c.field, 'ppd-mdprevcol' + (c.visible === false ? ' ppd-part-hidden' : '')) +
                ' style="' + esc(thc) + '">' + esc(c.label || title(c.field)) + ' <i class="bi bi-grip-vertical ppd-mdcol-grip"></i></th>';
        }).join('');
        var body = '';
        for (var i = 0; i < 3; i++) {
            var stripe = (st.striped && i % 2 === 1) ? 'background:var(--ppd-surface-soft,#f8fafc);' : '';
            body += '<tr style="' + stripe + '">' + cols.map(function (c) {
                return '<td style="text-align:' + (c.align || 'left') + ';padding:' + cellPad + ';border-bottom:1px solid var(--ppd-border,#eef2f7);' + (c.visible === false ? 'opacity:.35;' : '') + '">' +
                    '<span style="display:inline-block;width:' + (36 + (i * 13 % 40)) + 'px;height:8px;border-radius:4px;background:#e2e8f0;"></span></td>';
            }).join('') + '</tr>';
        }
        var t = st.title || title(r.detail_table);
        var titleCss = 'font-size:13px;color:' + (st.titleColor || 'inherit') + ';' + (st.titleBold ? 'font-weight:800;' : '');
        var badgeCss = reg.mdBadgeCss ? reg.mdBadgeCss(st.badgeSize || 'md', st.badgeShape || 'pill', st.badgeBg || '#e2e8f0', st.badgeColor || '#475569')
            : 'background:#e2e8f0;color:#475569;border-radius:10px;padding:1px 8px;font-size:10px;';
        var btnCss = function (bg, col) { return (bg ? 'background:' + bg + ';border-color:' + bg + ';' : '') + (col ? 'color:' + col + ';' : ''); };
        var childFn = (((kb.pagesConfig || {})[r.detail_table] || {}).functionality) || {};
        var headHtml = st.hideHeader ? '' :
            ('<strong ' + pt('rstitle', 'ppd-mdprevpart') + ' style="' + esc(titleCss) + '">' + reg.icon(st.headerIcon || 'bi-diagram-3') + ' ' + esc(t) + '</strong>' +
             '<span ' + pt('rsbadge', 'ppd-mdprevpart ppd-md-badge' + (st.badgeHide ? ' ppd-part-hidden' : '')) + ' style="' + esc(badgeCss) + '">n</span>' +
             '<span style="flex:1"></span>' +
             '<span ' + pt('rsadd', 'ppd-mdprevpart ppd-btn' + (st.addHide ? ' ppd-part-hidden' : '')) + ' style="font-size:10px;' + esc(btnCss(st.addBg, st.addColor)) + '">' + reg.icon(st.addIcon || (childFn.inlineAdd ? 'bi-plus-lg' : 'bi-plus-lg')) + ' ' + esc(st.addLabel || (childFn.inlineAdd ? 'Inline Add' : 'Add')) + '</span>' +
             (r.show_proceed_link ? '<span ' + pt('rsgo', 'ppd-mdprevpart ppd-btn' + (st.goHide ? ' ppd-part-hidden' : '')) + ' style="font-size:10px;' + esc(btnCss(st.goBg, st.goColor)) + '">' + reg.icon(st.goIcon || 'bi-box-arrow-up-right') + ' ' + esc(st.goLabel || 'View all') + '</span>' : ''));
        return '<div class="ppd-relsection' + (selected ? ' selected' : '') + '" data-ppd-id="' + esc(relId) + '" title="Master–Detail section — click a zone or component to style · drag columns to reorder" style="' + esc(secCss) + '">' +
            '<div ' + pt('rshead', 'ppd-mdprevhead') + ' style="' + esc(headCss) + '">' + headHtml + '<span class="ppd-relsection-tag">' + reg.icon('bi-diagram-3') + ' relation</span></div>' +
            '<div style="overflow:hidden"><table ' + pt('rsgrid', 'ppd-mdprevgrid') + ' style="width:100%;border-collapse:collapse;font-size:11px;"><thead><tr>' + head + '</tr></thead><tbody>' + body + '</tbody></table></div></div>';
    }

    // Whether the canvas preview should render dark. It FOLLOWS the console (app)
    // light/dark theme by default so toggling the top-right theme keeps the whole
    // Designer consistent — this is what stops dark input boxes appearing on a
    // light canvas. An explicit per-page `layout.dark` or the inspector's "Preview
    // dark (this session)" toggle still forces dark.
    function canvasDark(page) {
        if (store.ui('themeDark') === true) return true;
        if (page && page.layout && page.layout.dark === true) return true;
        return (document.documentElement.getAttribute('data-pp-theme') || '') === 'dark';
    }

    // Re-apply the canvas theme when the console (app) theme is toggled, so the
    // preview flips light/dark live with the top-right switcher. Bound once.
    (function watchAppTheme() {
        if (window.__ppdThemeWatch || !window.MutationObserver) return;
        window.__ppdThemeWatch = true;
        new MutationObserver(function () {
            var page = model.activePage();
            if (page && PPD.theme) PPD.theme.apply((page.layout && page.layout.theme) || PPD.theme.defaultId(), canvasDark(page));
        }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-pp-theme'] });
    })();

    function render() {
        var canvas = qs('#ppdCanvas');
        if (!canvas) return;
        PPD.renderMode = 'design';   // canvas always renders hidden parts (dimmed) so they stay selectable
        var u = store.ui();
        canvas.className = 'ppd-canvas ppd-device-' + (u.device || 'desktop');
        canvas.style.transform = 'scale(' + ((u.zoom || 100) / 100) + ')';

        var page = model.activePage();
        if (!page) {
            canvas.innerHTML = empty('Select a page to design', 'Pick a table page or common page from the left.', 'bi-layout-text-window');
            return;
        }
        activeCtx = ctxFor(page.table || u.table);
        var comps = model.components(page);
        var pageType = page.type || u.pageType;
        canvas.classList.add('ppd-page-' + String(pageType || 'page').replace(/[^a-z0-9_-]/gi, '-'));
        // The generated landing page wraps its components in .pp-menu-page, and
        // the menu-specific styling is scoped to it. Mirror that on the canvas
        // or the Designer shows the same tree with different styling.
        canvas.classList.toggle('pp-menu-page', String(pageType || '') === 'menu');
        if (page.settings && page.settings.locked) canvas.classList.add('ppd-canvas-locked');   // layout frozen — see model.lockedGuard()
        // Adjacent buttons + pagination render on one line (buttons left,
        // pagination right) — matching the generated app's footer row.
        var body = '';
        for (var ci = 0; ci < comps.length; ci++) {
            var cNode = comps[ci], cNext = comps[ci + 1];
            var isPair = cNext && ((cNode.type === 'buttons' && cNext.type === 'pagination') ||
                                   (cNode.type === 'pagination' && cNext.type === 'buttons'));
            if (isPair) {
                var btnNode = cNode.type === 'buttons' ? cNode : cNext;
                var pagNode = cNode.type === 'pagination' ? cNode : cNext;
                body += '<div class="ppd-bottom-row">' + renderNode(btnNode) + renderNode(pagNode) + '</div>';
                ci++;
            } else {
                body += renderNode(cNode);
            }
        }

        // Master–Detail: auto relation sections on view/edit pages (customizable)
        if (pageType === 'view' || pageType === 'edit') {
            var vrels = viewEditRels(page, pageType);
            for (var vi = 0; vi < vrels.length; vi++) body += relSectionHtml(page, vrels[vi]);
        }

        // print pages render as a real paper sheet at the chosen size/orientation
        canvas.classList.remove('ppd-print-sheet');
        canvas.style.width = ''; canvas.style.minHeight = '';
        var head = '<div class="ppd-page-head"><div><div class="t">' + esc(page.title || title(pageType)) + '</div>' +
            '<div class="s">' + esc((page.table || (u.scope === 'common' ? 'Common' : 'table')) + ' / ' + pageType) + '</div></div>' +
            '<span class="ppd-live-badge"><i class="bi bi-broadcast"></i> Live ' + esc(pageType) + '</span></div>';
        if (pageType === 'login') head = '';
        if (pageType === 'print' || pageType === 'pdf') {
            var pl = (page.layout && page.layout.paper) || { size: 'A4', orientation: 'portrait', margin: '18mm' };
            var dims = PAPER[pl.size] || PAPER.A4; var pw = dims[0], ph = dims[1];
            if (pl.orientation === 'landscape') { var tmp = pw; pw = ph; ph = tmp; }
            canvas.classList.add('ppd-print-sheet');
            canvas.style.width = Math.round(pw / 25.4 * 96) + 'px';
            canvas.style.minHeight = Math.round(ph / 25.4 * 96) + 'px';
            canvas.style.setProperty('--print-margin', pl.margin || '18mm');
            head = '';   // no designer chrome on the sheet
        }
        // Preserve scroll BEFORE we blow away the DOM: rebuilding innerHTML resets
        // the horizontal scroll of any wide grid (.ppd-table-wrap) and the canvas
        // viewport. Without this, selecting a column you had to scroll right to
        // reach snaps the view back to column 1 (reported UX bug). Keyed by the
        // grid node's stable data-ppd-id so it survives the re-render.
        var __cw = canvas.closest('.ppd-canvas-wrap');
        var __cwScroll = __cw ? { l: __cw.scrollLeft, t: __cw.scrollTop } : null;
        var __gridScroll = {};
        var __wraps0 = canvas.querySelectorAll('.ppd-table-wrap');
        for (var __wi = 0; __wi < __wraps0.length; __wi++) {
            var __w0 = __wraps0[__wi];
            if (__w0.scrollLeft || __w0.scrollTop) {
                var __wn = __w0.closest('[data-ppd-id]');
                if (__wn) __gridScroll[__wn.getAttribute('data-ppd-id')] = { l: __w0.scrollLeft, t: __w0.scrollTop };
            }
        }

        // Mirror the build's per-page Width / Alignment / Device cap (BuildWriter
        // pageChrome → .pp-content) on the canvas body, so the Designer is WYSIWYG:
        // a fixed or device-capped page shows its true content width, not full-bleed.
        var _L = page.layout || {};
        var _pw = _L.width || 'fluid', _pa = _L.align || 'center', _pdev = _L.device || 'desktop';
        var _max = _pw === 'fixed' ? 1080 : null;
        var _cap = _pdev === 'mobile' ? 620 : (_pdev === 'tablet' ? 1024 : null);
        if (_cap !== null) _max = _max === null ? _cap : Math.min(_max, _cap);
        var _bodyStyle = '';
        if (_max !== null && _pw !== 'full') {
            var _mg = _pa === 'left' ? '0 auto 0 0' : (_pa === 'right' ? '0 0 0 auto' : '0 auto');
            _bodyStyle = ' style="max-width:' + _max + 'px;margin:' + _mg + ';width:100%"';
        }
        // Root is a 12-col grid (like the generated app's .pp-form-body) so
        // top-level components honour Size & Layout width and match the build.
        canvas.innerHTML = head + '<div class="ppd-page-body ppd-children-zone ppd-form12"' + _bodyStyle + ' data-ppd-zone="__root__">' + body + '</div>';

        syncParts(canvas);
        injectCellbar(canvas);
        injectColResizers(canvas);
        bindSelection(canvas);
        bindTabs(canvas);
        bindFolds(canvas);
        bindDnd(canvas);
        bindColumnReorder(canvas);
        bindMdPrevReorder(canvas);
        bindFieldResize(canvas);
        upgradeFieldControls(canvas);
        upgradeDataWidgets(canvas);
        if (PPD.theme) PPD.theme.apply((page.layout && page.layout.theme) || PPD.theme.defaultId(), canvasDark(page));

        // Restore the scroll captured above, now that the new DOM exists — the
        // view stays put when you select a column deep in a wide grid.
        if (__cw && __cwScroll) { __cw.scrollLeft = __cwScroll.l; __cw.scrollTop = __cwScroll.t; }
        var __gk = Object.keys(__gridScroll);
        if (__gk.length) {
            var __esc = (window.CSS && CSS.escape) ? CSS.escape : function (s) { return String(s).replace(/["\\\]]/g, '\\$&'); };
            for (var __gi = 0; __gi < __gk.length; __gi++) {
                var __gn = canvas.querySelector('[data-ppd-id="' + __esc(__gk[__gi]) + '"]');
                var __gw = __gn && __gn.querySelector('.ppd-table-wrap');
                if (__gw) { __gw.scrollLeft = __gridScroll[__gk[__gi]].l; __gw.scrollTop = __gridScroll[__gk[__gi]].t; }
            }
        }

        store.emit('rendered', { page: page });
    }

    /* -------- hybrid field-control preview: upgrade approximations to real plugin HTML -------- */
    var fcCache = {};   // key -> rendered HTML (avoids refetching across re-renders)
    function upgradeFieldControls(canvas) {
        if (!PPD.api || !PPD.api.fieldPreview) return;
        // innerHTML-inserted <script> tags never execute — re-create them so
        // self-enhancing controls (e.g. the chip multi-select) render live.
        var liven = function (slot) {
            qsa('script', slot).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
        };
        qsa('[data-ppd-fc]', canvas).forEach(function (node) {
            var slot = node.querySelector('.ppd-fc-slot');
            if (!slot) return;   // inline booleans keep their compact approximation
            var cat = node.getAttribute('data-ppd-fc');
            var ctrl = node.getAttribute('data-ppd-fc-ctrl');
            var field = node.getAttribute('data-ppd-fc-field') || 'field';
            var setRaw = node.getAttribute('data-ppd-fc-set') || '{}';
            var key = cat + '|' + ctrl + '|' + field + '|' + setRaw;
            if (fcCache[key] != null) { slot.innerHTML = fcCache[key]; slot.classList.add('is-live'); liven(slot); return; }
            var settings = {}; try { settings = JSON.parse(setRaw); } catch (e) { }
            PPD.api.fieldPreview({ category: cat, control: ctrl, field: field, settings: settings }).then(function (res) {
                if (res && res.success && typeof res.html === 'string' && res.html !== '') {
                    fcCache[key] = res.html;
                    if (slot.isConnected) { slot.innerHTML = res.html; slot.classList.add('is-live'); liven(slot); }
                }
            }).catch(function () { });
        });
    }

    /* -------- live data widgets: real KPI numbers + chart data on the canvas -------- */
    var wdCache = {};   // key -> data or in-flight Promise (dedupes across re-renders)
    var WD_PAL = ['#2563eb', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316', '#64748b'];
    function wdFmt(v) {
        v = Number(v) || 0;
        return v.toLocaleString(undefined, { maximumFractionDigits: v === Math.round(v) ? 0 : 2 });
    }
    // Compact real chart for the canvas tile (the generated app draws the full-size one).
    function wdChartHtml(type, labels, values, color) {
        var i, n = values.length;
        if (!n) return '<div style="color:#94a3b8;font-size:11px;padding:14px 0;"><i class="bi bi-inbox"></i> No data</div>';
        if (type === 'pie' || type === 'doughnut') {
            var cx = 52, cy = 52, r = 46, ir = type === 'doughnut' ? 25 : 0;
            var total = 0; for (i = 0; i < n; i++) total += Math.max(0, values[i]);
            total = total || 1;
            var a0 = -Math.PI / 2, paths = '';
            for (i = 0; i < n; i++) {
                var frac = Math.max(0, values[i]) / total;
                if (frac <= 0) continue;
                var fill = WD_PAL[i % WD_PAL.length], tip = '<title>' + esc(labels[i]) + ': ' + wdFmt(values[i]) + '</title>';
                if (frac >= 0.999) {
                    paths += '<circle cx="' + cx + '" cy="' + cy + '" r="' + r + '" fill="' + fill + '">' + tip + '</circle>';
                    break;
                }
                var a1 = a0 + frac * 2 * Math.PI, big = (a1 - a0) > Math.PI ? 1 : 0;
                var x0 = cx + r * Math.cos(a0), y0 = cy + r * Math.sin(a0), x1 = cx + r * Math.cos(a1), y1 = cy + r * Math.sin(a1);
                if (ir) {
                    var xi0 = cx + ir * Math.cos(a1), yi0 = cy + ir * Math.sin(a1), xi1 = cx + ir * Math.cos(a0), yi1 = cy + ir * Math.sin(a0);
                    paths += '<path d="M' + x0 + ' ' + y0 + ' A' + r + ' ' + r + ' 0 ' + big + ' 1 ' + x1 + ' ' + y1 +
                        ' L' + xi0 + ' ' + yi0 + ' A' + ir + ' ' + ir + ' 0 ' + big + ' 0 ' + xi1 + ' ' + yi1 + ' Z" fill="' + fill + '">' + tip + '</path>';
                } else {
                    paths += '<path d="M' + cx + ' ' + cy + ' L' + x0 + ' ' + y0 + ' A' + r + ' ' + r + ' 0 ' + big + ' 1 ' + x1 + ' ' + y1 + ' Z" fill="' + fill + '">' + tip + '</path>';
                }
                a0 = a1;
            }
            var legend = '';
            for (i = 0; i < Math.min(n, 6); i++) {
                legend += '<div style="display:flex;align-items:center;gap:5px;font-size:10px;color:#475569;white-space:nowrap;">' +
                    '<span style="width:8px;height:8px;border-radius:2px;flex:none;background:' + WD_PAL[i % WD_PAL.length] + ';"></span>' +
                    esc(labels[i]) + ' · ' + wdFmt(values[i]) + '</div>';
            }
            if (n > 6) legend += '<div style="font-size:10px;color:#94a3b8;">+' + (n - 6) + ' more</div>';
            return '<div style="display:flex;gap:12px;align-items:center;min-width:0;">' +
                '<svg viewBox="0 0 104 104" style="width:96px;height:96px;flex:none;">' + paths + '</svg>' +
                '<div style="display:flex;flex-direction:column;gap:3px;min-width:0;overflow:hidden;">' + legend + '</div></div>';
        }
        if (type === 'hbar') {   // horizontal bars — compact canvas preview
            var Wh = 240, Hh = Math.max(60, Math.min(150, n * 18 + 8)), maxh = 1, ch = color || '#2563eb';
            for (i = 0; i < n; i++) if (values[i] > maxh) maxh = values[i];
            var bodyh = '', lblW = 52, barX = lblW + 4, barMax = Wh - barX - 6;
            for (i = 0; i < n; i++) {
                var yb = 4 + i * ((Hh - 8) / n), hb = (Hh - 8) / n - 4;
                var wb = Math.max(1, (Math.max(0, values[i]) / maxh) * barMax);
                var lb = String(labels[i]); if (lb.length > 9) lb = lb.slice(0, 8) + '…';
                bodyh += '<text x="' + lblW + '" y="' + (yb + hb / 2 + 3) + '" text-anchor="end" font-size="8" fill="#94a3b8">' + esc(lb) + '</text>' +
                    '<rect x="' + barX + '" y="' + yb.toFixed(1) + '" width="' + wb.toFixed(1) + '" height="' + hb.toFixed(1) + '" rx="2" fill="' + ch + '"><title>' + esc(labels[i]) + ': ' + wdFmt(values[i]) + '</title></rect>';
            }
            return '<svg viewBox="0 0 ' + Wh + ' ' + Hh + '" style="width:100%;max-width:320px;height:auto;">' + bodyh + '</svg>';
        }
        // bar / line / area on a shared 240x96 plot
        var W = 240, H = 96, top = 6, bottom = 14, plotH = H - top - bottom;
        var max = 1; for (i = 0; i < n; i++) if (values[i] > max) max = values[i];
        var body = '', c = color || '#2563eb';
        if (type === 'line' || type === 'area') {
            var pts = [];
            for (i = 0; i < n; i++) {
                var px = n === 1 ? W / 2 : (i / (n - 1)) * (W - 12) + 6;
                pts.push(px.toFixed(1) + ',' + (top + plotH - (Math.max(0, values[i]) / max) * plotH).toFixed(1));
            }
            if (type === 'area') body += '<polygon points="' + pts[0].split(',')[0] + ',' + (top + plotH) + ' ' + pts.join(' ') + ' ' +
                pts[n - 1].split(',')[0] + ',' + (top + plotH) + '" fill="' + c + '" opacity=".25"/>';
            body += '<polyline points="' + pts.join(' ') + '" fill="none" stroke="' + c + '" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>';
            for (i = 0; i < n; i++) {
                var xy = pts[i].split(',');
                body += '<circle cx="' + xy[0] + '" cy="' + xy[1] + '" r="2.5" fill="' + c + '"><title>' + esc(labels[i]) + ': ' + wdFmt(values[i]) + '</title></circle>';
            }
        } else {
            var bw = Math.min(26, (W - 8) / n - 4);
            for (i = 0; i < n; i++) {
                var bh = Math.max(1.5, (Math.max(0, values[i]) / max) * plotH);
                var bx = 4 + i * ((W - 8) / n) + ((W - 8) / n - bw) / 2;
                body += '<rect x="' + bx.toFixed(1) + '" y="' + (top + plotH - bh).toFixed(1) + '" width="' + bw.toFixed(1) + '" height="' + bh.toFixed(1) +
                    '" rx="2" fill="' + c + '"><title>' + esc(labels[i]) + ': ' + wdFmt(values[i]) + '</title></rect>';
            }
        }
        // baseline + x captions (only when they fit)
        body += '<line x1="2" y1="' + (top + plotH + 0.5) + '" x2="' + (W - 2) + '" y2="' + (top + plotH + 0.5) + '" stroke="#e2e8f0"/>';
        if (n <= 6) {
            for (i = 0; i < n; i++) {
                var lx = 4 + i * ((W - 8) / n) + ((W - 8) / n) / 2;
                var txt = String(labels[i]); if (txt.length > 8) txt = txt.slice(0, 7) + '…';
                body += '<text x="' + lx.toFixed(1) + '" y="' + (H - 3) + '" text-anchor="middle" font-size="8" fill="#94a3b8">' + esc(txt) + '</text>';
            }
        }
        return '<svg viewBox="0 0 ' + W + ' ' + H + '" style="width:100%;max-width:320px;height:auto;">' + body + '</svg>';
    }
    function upgradeDataWidgets(canvas) {
        if (!PPD.api || !PPD.api.widgetData) return;
        var page = model.activePage();
        if (!page) return;
        qsa('.ppd-type-kpi-stat, .ppd-type-chart', canvas).forEach(function (el) {
            var h = model.find(page, el.getAttribute('data-ppd-id'));
            if (!h || !h.node) return;
            var p = h.node.props || {};
            var isChart = el.classList.contains('ppd-type-chart');
            if (!p.table || (isChart && !p.labelColumn)) return;   // unbound → keep the placeholder
            var body = isChart
                ? { kind: 'chart', table: p.table, agg: p.agg || 'count', valueColumn: p.valueColumn || '', where: p.where || '', labelColumn: p.labelColumn, orderBy: p.orderBy || 'value DESC', limit: p.limit || 10 }
                : { kind: 'kpi', table: p.table, agg: p.agg || 'count', column: p.column || '', where: p.where || '' };
            var key = JSON.stringify(body);
            var paint = function (d) {
                if (!d || !d.success || !el.isConnected) return;
                if (isChart) {
                    var tile = el.querySelector('.ppd-chart');
                    if (!tile) return;
                    tile.innerHTML = (p.title ? '<strong style="font-size:13px;">' + esc(p.title) + '</strong>' : '') +
                        wdChartHtml(p.chartType || 'bar', d.labels || [], d.values || [], p.color) +
                        '<small style="color:#64748b;"><i class="bi bi-broadcast"></i> Live · ' + esc(p.table) + ' · ' + (d.labels || []).length + ' group(s)</small>';
                } else {
                    var v = el.querySelector('.ppd-stat .v');
                    if (v) v.innerHTML = esc(p.prefix || '') + wdFmt(d.value) + esc(p.suffix || '');
                    var cap = el.querySelector('.ppd-stat small');
                    if (cap) cap.innerHTML = '<i class="bi bi-broadcast"></i> Live · ' + cap.textContent;
                }
            };
            var cached = wdCache[key];
            if (cached && typeof cached.then !== 'function') { paint(cached); return; }
            var pr = cached || (wdCache[key] = PPD.api.widgetData(body));
            pr.then(function (d) { wdCache[key] = d || {}; paint(d); }).catch(function () { delete wdCache[key]; });
        });
        // KPI rows: each bound tile fetches its own live value.
        qsa('.ppd-type-kpi-row', canvas).forEach(function (el) {
            var h = model.find(page, el.getAttribute('data-ppd-id'));
            if (!h || !h.node) return;
            var tiles = qsa('.ppd-stat', el);
            ((h.node.props || {}).items || []).forEach(function (it, i) {
                if (!it || !it.table || !tiles[i]) return;
                var body = { kind: 'kpi', table: it.table, agg: it.agg || 'count', column: it.column || '', where: it.where || '' };
                var key = JSON.stringify(body);
                var tile = tiles[i];
                var paint = function (d) {
                    if (!d || !d.success || !tile.isConnected) return;
                    var v = tile.querySelector('.v');
                    if (v) v.textContent = (it.prefix || '') + wdFmt(d.value) + (it.suffix || '');
                    var cap = tile.querySelector('small');
                    if (cap && cap.textContent.indexOf('Live') < 0) cap.innerHTML = '<i class="bi bi-broadcast"></i> Live · ' + cap.textContent;
                };
                var cached = wdCache[key];
                if (cached && typeof cached.then !== 'function') { paint(cached); return; }
                var pr = cached || (wdCache[key] = PPD.api.widgetData(body));
                pr.then(function (d) { wdCache[key] = d || {}; paint(d); }).catch(function () { delete wdCache[key]; });
            });
        });
    }

    /* ---------------- drag-to-resize form-field width (per device) ---------------- */
    function bindFieldResize(canvas) {
        qsa('.ppd-form12 > .ppd-node', canvas).forEach(function (node) {
            if (node.querySelector(':scope > .ppd-resize-handle')) return;
            var handle = document.createElement('div');
            handle.className = 'ppd-resize-handle';
            handle.title = 'Drag to resize';
            node.appendChild(handle);
            handle.addEventListener('mousedown', function (e) {
                e.preventDefault(); e.stopPropagation();
                var id = node.getAttribute('data-ppd-id');
                var page = model.activePage(); var h = model.find(page, id); if (!h || !h.node) return;
                var fieldNode = h.node;
                var dev = store.ui('device') || 'desktop';
                var grid = node.parentNode;
                var colW = grid.getBoundingClientRect().width / 12;
                var startX = e.clientX;
                var startSpan = (fieldNode.props.spans && fieldNode.props.spans[dev]) || 12;
                node.setAttribute('draggable', 'false');
                node.classList.add('ppd-resizing');
                function move(ev) {
                    var ns = Math.max(1, Math.min(12, startSpan + Math.round((ev.clientX - startX) / colW)));
                    node.style.gridColumn = 'span ' + ns;
                    node.setAttribute('data-live-span', ns);
                }
                function up() {
                    document.removeEventListener('mousemove', move);
                    document.removeEventListener('mouseup', up);
                    node.setAttribute('draggable', 'true');
                    node.classList.remove('ppd-resizing');
                    var ns = parseInt(node.getAttribute('data-live-span'), 10) || startSpan;
                    if (ns !== startSpan) {
                        var spans = Object.assign({}, fieldNode.props.spans || {});
                        spans[dev] = ns;
                        model.updateProps(fieldNode.id, { spans: spans });
                    }
                }
                document.addEventListener('mousemove', move);
                document.addEventListener('mouseup', up);
            });
        });
    }

    /* ---------------- drag-to-reorder grid columns ---------------- */
    function reorderColumn(gridId, fromField, toField, after) {
        if (model.isPageLocked && model.isPageLocked()) { if (PPD.toast) PPD.toast('Page is locked — uncheck “Lock page” in Properties to edit', 'warn'); return; }
        var page = model.activePage(); if (!page) return;
        var h = model.find(page, gridId); if (!h || !h.node) return;
        var grid = h.node;
        // current columns: explicit, or materialized from the bound fields (e.g. the template)
        var cols = (grid.props.columns && grid.props.columns.length)
            ? grid.props.columns.map(function (c) { return c; })
            : (activeCtx.fields || []).map(function (f) { return { field: f.name, label: f.label || title(f.name), visible: true, sortable: true }; });
        var fi = -1, ti = -1;
        cols.forEach(function (c, i) { if (c.field === fromField) fi = i; if (c.field === toField) ti = i; });
        if (fi < 0 || ti < 0 || fromField === toField) return;
        var moved = cols.splice(fi, 1)[0];
        ti = -1; cols.forEach(function (c, i) { if (c.field === toField) ti = i; });
        cols.splice(after ? ti + 1 : ti, 0, moved);
        model.updateProps(grid.id, { columns: cols });
    }

    // insert a field from the Fields panel as a new grid column
    function addColumnAt(gridId, fieldName, targetField, after) {
        if (model.isPageLocked && model.isPageLocked()) { if (PPD.toast) PPD.toast('Page is locked — uncheck “Lock page” in Properties to edit', 'warn'); return; }
        var page = model.activePage(); if (!page) return;
        var h = model.find(page, gridId); if (!h || !h.node) return;
        var grid = h.node;
        var cols = (grid.props.columns && grid.props.columns.length)
            ? grid.props.columns.map(function (c) { return c; })
            : (activeCtx.fields || []).map(function (f) { return { field: f.name, label: f.label || title(f.name), visible: true, sortable: true }; });
        var existing = null;
        cols.forEach(function (c) { if (c.field === fieldName) existing = c; });
        if (existing) { if (existing.visible === false) { existing.visible = true; model.updateProps(grid.id, { columns: cols }); } return; }
        var f = (activeCtx.fields || []).filter(function (x) { return x.name === fieldName; })[0];
        var col = { field: fieldName, label: (f && f.label) || title(fieldName), visible: true, sortable: true };
        var ti = -1; cols.forEach(function (c, i) { if (c.field === targetField) ti = i; });
        if (ti < 0) cols.push(col); else cols.splice(after ? ti + 1 : ti, 0, col);
        model.updateProps(grid.id, { columns: cols });
    }

    var colDragField = null, colGridId = null;
    function clearColMarks(canvas) { qsa('.ppd-col-before,.ppd-col-after,.ppd-col-dragging', canvas).forEach(function (e) { e.classList.remove('ppd-col-before', 'ppd-col-after', 'ppd-col-dragging'); }); }
    function bindColumnReorder(canvas) {
        qsa('th.ppd-part[data-ppd-part^="head:"]', canvas).forEach(function (th) {
            th.setAttribute('draggable', 'true');
            th.classList.add('ppd-col-grip');
            th.addEventListener('dragstart', function (e) {
                e.stopPropagation();
                colDragField = th.getAttribute('data-ppd-part').split(':')[1];
                colGridId = th.getAttribute('data-ppd-id');
                e.dataTransfer.effectAllowed = 'move';
                try { e.dataTransfer.setData('text/ppd-col', colDragField); } catch (x) {}
                th.classList.add('ppd-col-dragging');
            });
            th.addEventListener('dragend', function () { colDragField = null; colGridId = null; clearColMarks(canvas); });
            th.addEventListener('dragover', function (e) {
                var isField = e.dataTransfer.types && [].indexOf.call(e.dataTransfer.types, 'text/ppd-field') > -1;
                if (!colDragField && !isField) return;
                e.preventDefault(); e.stopPropagation();
                clearColMarks(canvas);
                var after = (e.clientX - th.getBoundingClientRect().left) > th.offsetWidth / 2;
                th.classList.add(after ? 'ppd-col-after' : 'ppd-col-before');
            });
            th.addEventListener('drop', function (e) {
                var fieldName = e.dataTransfer.getData('text/ppd-field');
                if (!colDragField && !fieldName) return;
                e.preventDefault(); e.stopPropagation();
                var after = th.classList.contains('ppd-col-after');
                clearColMarks(canvas);
                var target = th.getAttribute('data-ppd-part').split(':')[1];
                if (colDragField) reorderColumn(colGridId, colDragField, target, after);          // reorder existing
                else if (fieldName) addColumnAt(th.getAttribute('data-ppd-id'), fieldName, target, after); // add a field as a column
                colDragField = null; colGridId = null;
            });
        });
    }

    // ── Master–Detail child-preview: drag a column header to reorder ──
    // Order lives in detailsCol.preview.cols[relId] (materialized from the
    // child table's fields on first drag via reg.mdPrevCols).
    // One binder covers all three child-grid column kinds:
    //   mdprevcol:<relId>:<field>  list preview panel  → detailsCol.preview.cols[relId]
    //   dgcol:<field>              detail-grid component → props.columnsCfg
    //   rscol:<field>              view/edit relation section → page.relationStyles[relId].cols
    var mdColDrag = null;   // { ownerId, kind, scope, field }
    function mdColInfo(th) {
        var seg = th.getAttribute('data-ppd-part').split(':');
        var kind = seg[0];
        return {
            ownerId: th.getAttribute('data-ppd-id'),
            kind: kind,
            scope: kind === 'mdprevcol' ? seg[1] : '',            // relId inside the part key
            field: kind === 'mdprevcol' ? seg.slice(2).join(':') : seg.slice(1).join(':'),
        };
    }
    function bindMdPrevReorder(canvas) {
        qsa('th.ppd-part[data-ppd-part^="mdprevcol:"], th.ppd-part[data-ppd-part^="dgcol:"], th.ppd-part[data-ppd-part^="rscol:"]', canvas).forEach(function (th) {
            th.setAttribute('draggable', 'true');
            th.addEventListener('dragstart', function (e) {
                e.stopPropagation();
                mdColDrag = mdColInfo(th);
                e.dataTransfer.effectAllowed = 'move';
                try { e.dataTransfer.setData('text/ppd-mdcol', mdColDrag.field); } catch (x) {}
                th.classList.add('ppd-col-dragging');
            });
            th.addEventListener('dragend', function () { mdColDrag = null; clearColMarks(canvas); });
            th.addEventListener('dragover', function (e) {
                if (!mdColDrag) return;
                var t = mdColInfo(th);
                if (t.ownerId !== mdColDrag.ownerId || t.kind !== mdColDrag.kind || t.scope !== mdColDrag.scope) return;   // same child grid only
                e.preventDefault(); e.stopPropagation();
                clearColMarks(canvas);
                var after = (e.clientX - th.getBoundingClientRect().left) > th.offsetWidth / 2;
                th.classList.add(after ? 'ppd-col-after' : 'ppd-col-before');
            });
            th.addEventListener('drop', function (e) {
                if (!mdColDrag) return;
                e.preventDefault(); e.stopPropagation();
                var after = th.classList.contains('ppd-col-after');
                clearColMarks(canvas);
                var t = mdColInfo(th);
                if (t.ownerId === mdColDrag.ownerId && t.kind === mdColDrag.kind && t.scope === mdColDrag.scope && t.field !== mdColDrag.field) {
                    reorderMdCol(mdColDrag, t.field, after);
                }
                mdColDrag = null;
            });
        });
    }
    function moveInList(cols, fromField, toField, after) {
        var fi = -1, ti = -1;
        cols.forEach(function (c, i) { if (c.field === fromField) fi = i; });
        if (fi < 0) return null;
        var moved = cols.splice(fi, 1)[0];
        cols.forEach(function (c, i) { if (c.field === toField) ti = i; });
        if (ti < 0) return null;
        cols.splice(after ? ti + 1 : ti, 0, moved);
        return cols;
    }
    function reorderMdCol(drag, toField, after) {
        var page = model.activePage(); if (!page) return;
        var relations = ((window.PpDesignerBootstrap || {}).relations) || [];
        if (drag.kind === 'mdprevcol') {
            var h = model.find(page, drag.ownerId); if (!h || !h.node) return;
            var dc = Object.assign({}, h.node.props.detailsCol || {});
            dc.preview = Object.assign({}, dc.preview);
            var rel = relations.filter(function (x) { return x.id === drag.scope; })[0] || {};
            var cols = moveInList((reg.mdPrevCols ? reg.mdPrevCols(dc.preview, rel) : []).map(function (c) { return Object.assign({}, c); }), drag.field, toField, after);
            if (!cols) return;
            dc.preview.cols = Object.assign({}, dc.preview.cols);
            dc.preview.cols[drag.scope] = cols;
            model.updateProps(drag.ownerId, { detailsCol: dc });
        } else if (drag.kind === 'dgcol') {
            var h2 = model.find(page, drag.ownerId); if (!h2 || !h2.node) return;
            var cols2 = moveInList((reg.dgCols ? reg.dgCols(h2.node.props || {}) : []).map(function (c) { return Object.assign({}, c); }), drag.field, toField, after);
            if (cols2) model.updateProps(drag.ownerId, { columnsCfg: cols2 });
        } else if (drag.kind === 'rscol' && drag.ownerId.indexOf('rel:') === 0) {
            var relId = drag.ownerId.slice(4);
            var rel2 = relations.filter(function (x) { return x.id === relId; })[0] || {};
            var rs = ((page.relationStyles || {})[relId]) || {};
            var cols3 = moveInList(reg.relCols(rs, rel2).map(function (c) { return Object.assign({}, c); }), drag.field, toField, after);
            if (!cols3) return;
            model.updatePage({ relationStyles: Object.assign({}, page.relationStyles, kvObj(relId, Object.assign({}, rs, { cols: cols3 }))) });
        }
    }
    function kvObj(k, v) { var o = {}; o[k] = v; return o; }

    // highlight the selected sub-part (deep selection) after innerHTML is set
    function syncParts(canvas) {
        var id = store.ui('selectedId'), pp = store.ui('selectedPart');
        var multi = String(store.ui('selectedCells') || '').split(',').filter(Boolean)
            .map(function (k) { return 'cell:' + k; });
        qsa('.ppd-part', canvas).forEach(function (el) {
            var mine = el.getAttribute('data-ppd-id') === id;
            var part = el.getAttribute('data-ppd-part');
            el.classList.toggle('ppd-part-selected', !!pp && mine && part === pp);
            el.classList.toggle('ppd-part-multi', mine && multi.indexOf(part) > -1 && part !== pp);
        });
        // mark the owning node so it shows a subtle context outline when a part is selected
        qsa('.ppd-node', canvas).forEach(function (n) {
            n.classList.toggle('ppd-has-part', !!pp && n.getAttribute('data-ppd-id') === id);
        });
    }

    // Floating quick-op toolbar on the selected Cell Grid cell: merge / split /
    // insert / delete right on the canvas, no trip to the properties panel.
    // Injected fresh each render (the canvas re-renders on every selection
    // change), so no listeners live on it — clicks are handled by the
    // delegated [data-cellop] branch in bindSelection.
    function injectCellbar(canvas) {
        var id = store.ui('selectedId'), pp = String(store.ui('selectedPart') || '');
        if (!id || pp.indexOf('cell:') !== 0) return;
        var page = model.activePage(); if (!page) return;
        var hit = model.find(page, id);
        if (!hit || hit.node.type !== 'layout-grid') return;
        var el = null;
        qsa('.ppd-lgcell', canvas).forEach(function (cand) {
            if (!el && cand.getAttribute('data-ppd-id') === id && cand.getAttribute('data-ppd-part') === pp) el = cand;
        });
        if (!el) return;
        var a = pp.slice(5).split(':'), r = parseInt(a[0], 10) || 0, c = parseInt(a[1], 10) || 0;
        var cfg = ((hit.node.props || {}).cells || {})[r + ':' + c] || {};
        var merged = Math.max(1, parseInt(cfg.rs, 10) || 1) > 1 || Math.max(1, parseInt(cfg.cs, 10) || 1) > 1;
        var nSel = String(store.ui('selectedCells') || '').split(',').filter(Boolean).length;
        var b = function (op, ic, tip, danger) {
            return '<button type="button"' + (danger ? ' class="danger"' : '') + ' data-cellop="' + op + '" title="' + esc(tip) + '"><i class="bi ' + ic + '"></i></button>';
        };
        var html;
        if (nSel > 1) {
            html = '<button type="button" class="is-merge" data-cellop="merge-selected" title="Merge the selected cells into one">'
                + '<i class="bi bi-union"></i> Merge ' + nSel + ' cells</button>';
        } else {
            html = b('merge-right', 'bi-arrow-bar-right', 'Merge with the cell to the right')
                + b('merge-down', 'bi-arrow-bar-down', 'Merge with the cell below')
                + (merged ? b('unmerge', 'bi-grid-3x3', 'Split this merged cell back apart') : '')
                + '<span class="sep"></span>'
                + b('row-below', 'bi-plus-square', 'Insert a row below')
                + b('col-right', 'bi-plus-square-dotted', 'Insert a column to the right')
                + '<span class="sep"></span>'
                + b('delrow', 'bi-dash-square', 'Delete this row', true)
                + b('delcol', 'bi-dash-square-dotted', 'Delete this column', true);
        }
        var bar = document.createElement('div');
        bar.className = 'ppd-cellbar';
        bar.innerHTML = html;
        el.appendChild(bar);
    }

    // ---- Cell Grid canvas helpers (shared by resize grips + keyboard nav) ----
    // Master cells with their effective spans, row-major (mirror of lgCovered).
    function lgMasters(p) {
        var R = Math.max(1, parseInt(p.rows, 10) || 1), C = Math.max(1, parseInt(p.cols, 10) || 1);
        var cov = {}, masters = [];
        for (var r = 0; r < R; r++) for (var c = 0; c < C; c++) {
            if (cov[r + ':' + c]) continue;
            var cfg = (p.cells || {})[r + ':' + c] || {};
            var rs = Math.min(Math.max(1, parseInt(cfg.rs, 10) || 1), R - r);
            var cs = Math.min(Math.max(1, parseInt(cfg.cs, 10) || 1), C - c);
            masters.push({ r: r, c: c, rs: rs, cs: cs });
            for (var i = r; i < r + rs; i++) for (var j = c; j < c + cs; j++)
                if (i !== r || j !== c) cov[i + ':' + j] = true;
        }
        return masters;
    }
    function masterCovering(masters, r, c) {
        for (var i = 0; i < masters.length; i++) {
            var m = masters[i];
            if (r >= m.r && r < m.r + m.rs && c >= m.c && c < m.c + m.cs) return m;
        }
        return null;
    }
    function selectedGridNode() {
        var id = store.ui('selectedId'); if (!id) return null;
        var page = model.activePage(); if (!page) return null;
        var hit = model.find(page, id);
        return (hit && hit.node.type === 'layout-grid') ? hit.node : null;
    }

    // ---- drag-to-resize column tracks on the selected Cell Grid ----
    // Grips sit in the column gaps; dragging previews via inline
    // grid-template-columns (px) and commits fr-based colWidths on mouseup
    // (one undo step via model.updateProps → re-render re-injects fresh grips).
    function injectColResizers(canvas) {
        var node = selectedGridNode(); if (!node) return;
        var C = Math.max(1, Math.min(12, parseInt(node.props.cols, 10) || 1));
        if (C < 2) return;
        var nodeEl = null;
        qsa('.ppd-node', canvas).forEach(function (el) {
            if (!nodeEl && el.getAttribute('data-ppd-id') === node.id) nodeEl = el;
        });
        if (!nodeEl) return;
        var grid = null;
        qsa('.ppd-lgrid', nodeEl).forEach(function (el) {
            if (!grid && el.closest('.ppd-node') === nodeEl) grid = el;
        });
        if (!grid) return;
        var cs = window.getComputedStyle(grid);
        var tracks = cs.gridTemplateColumns.split(' ').map(parseFloat)
            .filter(function (v) { return !isNaN(v); });
        if (tracks.length !== C) return;
        var gap = parseFloat(cs.columnGap) || 0;
        var MIN = 60;                                     // layout px per track
        function makeGrip(iBound, center) {
            var h = document.createElement('div');
            h.className = 'ppd-colgrip';
            h.title = 'Drag to resize columns';
            h.style.left = (center - 4.5) + 'px';
            h.addEventListener('mousedown', function (e) {
                e.preventDefault(); e.stopPropagation();
                // zoom-safe: canvas is transform-scaled, so screen px ≠ layout px
                var rectW = grid.getBoundingClientRect().width;
                var scale = rectW ? grid.offsetWidth / rectW : 1;
                var startX = e.clientX, cur = null;
                document.body.classList.add('ppd-col-resizing');
                function onMove(ev) {
                    var d = (ev.clientX - startX) * scale;
                    var sum = tracks[iBound] + tracks[iBound + 1];
                    var a = Math.min(Math.max(MIN, tracks[iBound] + d), sum - MIN);
                    cur = tracks.slice(); cur[iBound] = a; cur[iBound + 1] = sum - a;
                    grid.style.gridTemplateColumns = cur.map(function (v) { return v.toFixed(1) + 'px'; }).join(' ');
                }
                function onUp() {
                    document.removeEventListener('mousemove', onMove);
                    document.removeEventListener('mouseup', onUp);
                    document.body.classList.remove('ppd-col-resizing');
                    if (!cur) return;
                    var total = 0; cur.forEach(function (v) { total += v; });
                    var csv = cur.map(function (v) {
                        return (Math.round((v / (total / C)) * 100) / 100) + 'fr';
                    }).join(',');
                    model.updateProps(node.id, { colWidths: csv });
                }
                document.addEventListener('mousemove', onMove);
                document.addEventListener('mouseup', onUp);
            });
            grid.appendChild(h);
        }
        var cum = 0;
        for (var i = 0; i < C - 1; i++) {
            cum += tracks[i];
            makeGrip(i, cum + gap * i + gap / 2);
        }
    }

    // ---- keyboard navigation between cells (bound once) ----
    // Arrows move the selection to the adjacent master cell (merge-aware),
    // Enter drills into the cell's first component, Escape steps back out to
    // the grid node. Inert while typing anywhere.
    var cellKeysBound = false;
    function bindCellKeys() {
        if (cellKeysBound) return; cellKeysBound = true;
        document.addEventListener('keydown', function (e) {
            var k = e.key;
            if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape'].indexOf(k) < 0) return;
            var t = e.target;
            if (t && t.closest && t.closest('input, textarea, select, [contenteditable], .ppd-rename')) return;
            var pp = String(store.ui('selectedPart') || '');
            if (pp.indexOf('cell:') !== 0) return;
            var page = model.activePage(); if (!page) return;
            var hit = model.find(page, store.ui('selectedId'));
            if (!hit || hit.node.type !== 'layout-grid') return;
            var p = hit.node.props || {};
            var R = Math.max(1, parseInt(p.rows, 10) || 1), C = Math.max(1, parseInt(p.cols, 10) || 1);
            var a = pp.slice(5).split(':'), r = parseInt(a[0], 10) || 0, c = parseInt(a[1], 10) || 0;
            if (k === 'Enter') {
                var tab = r * C + c;
                var kid = (hit.node.children || []).filter(function (ch) {
                    return (typeof ch.tab === 'number' ? ch.tab : 0) === tab;
                })[0];
                if (kid) { e.preventDefault(); store.setUi({ selectedId: kid.id, selectedPart: '', selectedCells: '' }); }
                return;
            }
            if (k === 'Escape') {
                e.preventDefault();
                store.setUi({ selectedPart: '', selectedCells: '' });
                return;
            }
            e.preventDefault();                            // arrows never scroll while cell-navigating
            var masters = lgMasters(p);
            var cur = masterCovering(masters, r, c) || { r: r, c: c, rs: 1, cs: 1 };
            var target = null;
            if (k === 'ArrowRight' && cur.c + cur.cs < C) target = masterCovering(masters, cur.r, cur.c + cur.cs);
            else if (k === 'ArrowLeft' && cur.c > 0) target = masterCovering(masters, cur.r, cur.c - 1);
            else if (k === 'ArrowDown' && cur.r + cur.rs < R) target = masterCovering(masters, cur.r + cur.rs, cur.c);
            else if (k === 'ArrowUp' && cur.r > 0) target = masterCovering(masters, cur.r - 1, cur.c);
            if (target) store.setUi({ selectedPart: 'cell:' + target.r + ':' + target.c, selectedCells: '' });
        });
    }

    function empty(t, s, ic) {
        return '<div class="ppd-empty"><div class="ppd-empty-icon"><i class="bi ' + ic + '"></i></div><strong>' + esc(t) + '</strong><span>' + esc(s) + '</span></div>';
    }

    /* ---------------- selection (delegated), tools, inline rename ---------------- */
    function bindSelection(canvas) {
        // These are DELEGATED listeners on the persistent #ppdCanvas element (only
        // its innerHTML is replaced on render). Bind ONCE — re-binding on every
        // render leaked a click/dblclick handler each time, so after many renders a
        // single Duplicate click fired dozens of times, each triggering another
        // render → runaway copy loop that froze the Designer.
        if (canvas.__ppdBound) return;
        canvas.__ppdBound = true;
        bindCellKeys();
        canvas.addEventListener('click', function (e) {
            // column-resize grips handle their own mousedown; the click that
            // follows a drag must not change the selection
            if (e.target.closest('.ppd-colgrip')) { e.stopPropagation(); return; }
            // floating cell-toolbar quick ops (Cell Grid) — run before selection
            var opBtn = e.target.closest('.ppd-cellbar [data-cellop]');
            if (opBtn) {
                e.stopPropagation(); e.preventDefault();
                var opPage = model.activePage(); if (!opPage) return;
                var opHit = model.find(opPage, store.ui('selectedId'));
                if (opHit && PPD.inspector && PPD.inspector.runCellOp) PPD.inspector.runCellOp(opHit.node, opBtn.getAttribute('data-cellop'));
                return;
            }
            // node tools (duplicate / delete) act on the owning node
            var actBtn = e.target.closest('[data-act]');
            if (actBtn) {
                e.stopPropagation();
                var node = actBtn.closest('.ppd-node'); if (!node) return;
                var nid = node.getAttribute('data-ppd-id');
                var act = actBtn.getAttribute('data-act');
                // Field-controls button (shown on a selected field node) → the same
                // Edit/View/Filter/Validation studio as double-click, but reliable on
                // Add/Edit/View where a field sits inside a layout-grid cell.
                if (act === 'fc') {
                    // The field name is embedded on the button so this never depends on
                    // re-resolving the node from the model (robust on Add/Edit/View).
                    var fpage = model.activePage();
                    var ffield = actBtn.getAttribute('data-fc-field') || '';
                    var flabel = actBtn.getAttribute('data-fc-label') || ffield;
                    if (!ffield) {
                        var fh = fpage && model.find(fpage, nid);
                        var fp = fh && fh.node && fh.node.props;
                        if (fp && fp.field) { ffield = fp.field; flabel = fp.label || fh.node.label || ffield; }
                    }
                    if (fpage && fpage.table && ffield && PPD.inspector && PPD.inspector.openFieldControls) {
                        PPD.inspector.openFieldControls(fpage.table, ffield, flabel);
                    }
                    return;
                }
                if (act === 'dup') { var dupId = model.duplicate(nid); store.setUi({ selectedId: dupId, selectedPart: '', selectedCells: '' }); }
                else { model.remove(nid); store.setUi({ selectedId: '', selectedPart: '', selectedCells: '' }); }
                return;
            }
            // Alt/Option-click anywhere inside a Cell Grid cell selects the CELL
            // itself — even directly over a component that fills it. The quickest
            // way to reach any cell without hunting for its empty margin.
            if (e.altKey) {
                var altCell = e.target.closest('.ppd-lgcell');
                if (altCell && altCell.getAttribute('data-ppd-part')) {
                    e.stopPropagation(); e.preventDefault();
                    store.setUi({ selectedId: altCell.getAttribute('data-ppd-id'), selectedPart: altCell.getAttribute('data-ppd-part'), selectedCells: '' });
                    return;
                }
            }
            // deepest selectable sub-part wins. A part may CONTAIN component
            // nodes (a Cell Grid cell wraps its children): when the clicked
            // node sits inside the part, the node is deeper — let it win, so
            // clicking a field selects the field and clicking the cell's empty
            // area selects the cell.
            var partEl = e.target.closest('.ppd-part');
            var partInnerNode = e.target.closest('.ppd-node');
            if (partEl && !(partInnerNode && partEl.contains(partInnerNode))) {
                e.stopPropagation();
                var pid = partEl.getAttribute('data-ppd-id'), ppart = partEl.getAttribute('data-ppd-part') || '';
                // ⌘/Ctrl-click another cell of the SAME Cell Grid → toggle it in
                // the multi-cell selection ("Merge selected" works on the set)
                if ((e.metaKey || e.ctrlKey) && ppart.indexOf('cell:') === 0 &&
                        pid === store.ui('selectedId') &&
                        String(store.ui('selectedPart') || '').indexOf('cell:') === 0) {
                    var set = String(store.ui('selectedCells') || '').split(',').filter(Boolean);
                    var anchor = String(store.ui('selectedPart')).slice(5);
                    if (set.indexOf(anchor) < 0) set.unshift(anchor);
                    var key = ppart.slice(5), at = set.indexOf(key);
                    if (at > -1) { if (key !== anchor) set.splice(at, 1); }
                    else set.push(key);
                    store.setUi({ selectedCells: set.length > 1 ? set.join(',') : '' });
                    return;
                }
                store.setUi({ selectedId: pid, selectedPart: ppart, selectedCells: '' });
                return;
            }
            // Master–Detail relation section (not a model node) → select for styling
            var relEl = e.target.closest('.ppd-relsection');
            if (relEl) { e.stopPropagation(); store.setUi({ selectedId: relEl.getAttribute('data-ppd-id'), selectedPart: '', selectedCells: '' }); return; }
            // otherwise select the nearest component node
            var nodeEl = e.target.closest('.ppd-node');
            if (nodeEl) { e.stopPropagation(); store.setUi({ selectedId: nodeEl.getAttribute('data-ppd-id'), selectedPart: '', selectedCells: '' }); return; }
            // background → clear
            store.setUi({ selectedId: '', selectedPart: '', selectedCells: '' });
        });
        // double-click a node header to rename (a part that CONTAINS the node —
        // e.g. a Cell Grid cell — must not block renaming the child inside it)
        canvas.addEventListener('dblclick', function (e) {
            var page = model.activePage();
            var canOpen = page && page.table && PPD.inspector && PPD.inspector.openFieldControls;
            var nodeEl = e.target.closest('.ppd-node');
            // 1) A FIELD component (Add / Edit / View forms) — double-click ANYWHERE
            //    inside it to assign its Edit/View/Filter/Validation controls. Checked
            //    FIRST so a field that lives inside a layout-grid cell (a .ppd-part with
            //    data-ppd-part="cell:<r>:<c>") resolves to the real field, not the cell
            //    coordinates — that mismatch is why the modal never opened on forms.
            if (nodeEl && canOpen) {
                var fhit = model.find(page, nodeEl.getAttribute('data-ppd-id'));
                var fprops = fhit && fhit.node && fhit.node.props;
                if (fhit && fhit.node && fhit.node.type === 'field' && fprops && fprops.field) {
                    e.stopPropagation();
                    PPD.inspector.openFieldControls(page.table, fprops.field, fprops.label || fhit.node.label);
                    return;
                }
            }
            // 2) A GRID COLUMN (list page) is a "part" (field:/head:/cell:/filter:<col>)
            //    of the table-grid — double-click it to assign that column's controls.
            //    Ignore layout-grid form cells (cell:<row>:<col> coordinates), whose
            //    real field node was already handled above.
            var partEl = e.target.closest('.ppd-part');
            if (partEl && canOpen) {
                var m = /^(?:field|cell|head|filter):(.+)$/.exec(partEl.getAttribute('data-ppd-part') || '');
                if (m && m[1] && !/^\d+:\d+$/.test(m[1])) { e.stopPropagation(); PPD.inspector.openFieldControls(page.table, m[1], m[1]); return; }
            }
            // 3) Otherwise double-click a node header to rename it (a part that CONTAINS
            //    the node — e.g. a Cell Grid cell — must not block renaming its child).
            var pp = e.target.closest('.ppd-part');
            if (!nodeEl || (pp && !pp.contains(nodeEl))) return;
            e.stopPropagation();
            // A FIELD node is never inline-renamed — its label + controls are edited
            // in the Field-controls modal / inspector. Open the modal instead so
            // double-click on a form field is never a confusing rename.
            var nHit = model.find(page, nodeEl.getAttribute('data-ppd-id'));
            if (nHit && nHit.node && nHit.node.type === 'field') {
                if (canOpen && nHit.node.props && nHit.node.props.field) {
                    PPD.inspector.openFieldControls(page.table, nHit.node.props.field, nHit.node.props.label || nHit.node.label);
                }
                return;
            }
            startRename(nodeEl);
        });
    }

    function startRename(el) {
        var id = el.getAttribute('data-ppd-id');
        var page = model.activePage(); if (!page) return;
        var hit = model.find(page, id); if (!hit) return;
        var inp = document.createElement('input');
        inp.className = 'ppd-rename';
        inp.value = hit.node.label || hit.node.type;
        var tools = el.querySelector('.ppd-node-tools');
        el.insertBefore(inp, tools.nextSibling);
        inp.focus(); inp.select();
        function commit() { var v = inp.value.trim(); if (v) model.updateNode(id, { label: v }); else render(); }
        inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); commit(); } else if (e.key === 'Escape') render(); });
        inp.addEventListener('blur', commit);
    }

    /* ---------------- drag & drop ---------------- */
    var dragId = null;

    function inferField(name) {
        var f = store.fieldsFor((model.activePage() || {}).table).filter(function (x) { return x.name === name; })[0] || { name: name };
        var t = String(f.type || '').toLowerCase();
        var inputType = (t.indexOf('int') > -1 || t.indexOf('decimal') > -1 || t.indexOf('float') > -1) ? 'number'
            : (t.indexOf('date') > -1 || t.indexOf('time') > -1) ? 'date'
            : (t.indexOf('text') > -1) ? 'textarea'
            : (t.indexOf('enum') > -1) ? 'select' : 'text';
        return { name: name, label: f.label || title(name), inputType: inputType };
    }

    function addFromPalette(type, fieldName, opts) {
        var id;
        if (type === 'field' && fieldName) {
            var info = inferField(fieldName);
            id = model.add('field', opts);
            model.updateProps(id, { field: info.name, inputType: info.inputType });
            model.updateNode(id, { label: info.label });
        } else {
            id = model.add(type, opts);
        }
        // Tag palette-added components so "Reset defaults, keep my additions" can
        // preserve them (default template chrome carries no origin flag).
        if (id) model.updateNode(id, { origin: 'user' });
        return id;
    }

    // Tab bar (canvas): click a tab to switch which pane is shown/editable.
    // Ephemeral (not persisted) — just changes which drop zone is active.
    function bindTabs(canvas) {
        qsa('[data-pptab-canvas]', canvas).forEach(function (btn) {
            btn.setAttribute('draggable', 'false');
            btn.addEventListener('mousedown', function (e) { e.stopPropagation(); });
            btn.addEventListener('click', function (e) {
                e.preventDefault(); e.stopPropagation();
                var parts = btn.getAttribute('data-pptab-canvas').split('|');
                tabState[parts[0]] = parseInt(parts[1], 10) || 0;
                render();
            });
        });
    }

    // Section fold (canvas): click the header chevron to preview collapse/expand.
    // Ephemeral (not persisted) — just previews the built-app fold on the canvas.
    function bindFolds(canvas) {
        qsa('[data-ppfold-canvas]', canvas).forEach(function (ic) {
            ic.addEventListener('mousedown', function (e) { e.stopPropagation(); });
            ic.addEventListener('click', function (e) {
                e.preventDefault(); e.stopPropagation();
                var id = ic.getAttribute('data-ppfold-canvas');
                var node = model.find(model.activePage(), id);
                foldState[id] = !sectionFoldOf(node || { id: id, props: {} });
                render();
            });
        });
    }

    function bindDnd(canvas) {
        // node-level: reorder + drop relative to a sibling
        qsa('.ppd-node', canvas).forEach(function (el) {
            el.addEventListener('dragstart', function (e) {
                e.stopPropagation();
                dragId = el.getAttribute('data-ppd-id');
                e.dataTransfer.effectAllowed = 'move';
                try { e.dataTransfer.setData('text/ppd-move', dragId); } catch (x) {}
            });
            el.addEventListener('dragend', function () { dragId = null; clearMarks(canvas); });
            el.addEventListener('dragover', function (e) {
                if (e.target.closest('.ppd-children-zone') !== el.parentNode &&
                    !el.contains(e.target.closest('.ppd-children-zone'))) { /* still ok */ }
                e.preventDefault(); e.stopPropagation();
                clearMarks(canvas);
                var after = (e.clientY - el.getBoundingClientRect().top) > el.offsetHeight / 2;
                el.classList.add(after ? 'ppd-drop-after' : 'ppd-drop-before');
            });
            el.addEventListener('drop', function (e) {
                e.preventDefault(); e.stopPropagation();
                var after = el.classList.contains('ppd-drop-after');
                clearMarks(canvas);
                var targetId = el.getAttribute('data-ppd-id');
                var paletteType = e.dataTransfer.getData('text/ppd-type');
                var fieldName = e.dataTransfer.getData('text/ppd-field');
                if (paletteType) {
                    var page = model.activePage();
                    var hit = page && model.find(page, targetId);
                    var parentId = hit && hit.parent ? hit.parent.id : null;
                    var idx = hit ? hit.list.indexOf(hit.node) + (after ? 1 : 0) : undefined;
                    // Inherit the sibling's zone. Without this a component dropped
                    // NEXT TO an existing child fell back to tab 0 — so in a
                    // multi-zone container (Group / Card) the second and every
                    // later drop silently jumped into the header, which looked
                    // like "the container only accepts one component".
                    var sibTab = hit && hit.node && typeof hit.node.tab === 'number' ? hit.node.tab : null;
                    var nid = addFromPalette(paletteType, fieldName, { parentId: parentId, index: idx, tab: sibTab });
                    if (sibTab != null) model.setTab(nid, sibTab);
                    store.setUi({ selectedId: nid });
                } else if (dragId && dragId !== targetId) {
                    var hitT = model.find(model.activePage(), targetId);
                    var tTab = hitT && hitT.node && typeof hitT.node.tab === 'number' ? hitT.node.tab : null;
                    model.move(dragId, targetId, after ? 'after' : 'before');
                    if (tTab != null) model.setTab(dragId, tTab);
                    store.setUi({ selectedId: dragId });
                }
                dragId = null;
            });
        });

        // zone-level: drop into a container (or page root) at the end.
        // Defer ONLY when hovering a CHILD node inside this zone (that node
        // handles before/after insertion). When hovering the zone's own empty
        // space, `closest('.ppd-node')` finds the container node — which the
        // zone does NOT contain — so we correctly accept the drop INTO it.
        qsa('.ppd-children-zone', canvas).forEach(function (zone) {
            var overChild = function (e) { var nn = e.target.closest('.ppd-node'); return nn && zone.contains(nn); };
            zone.addEventListener('dragover', function (e) {
                if (overChild(e)) return; // a child node handles its own
                e.preventDefault(); e.stopPropagation();
                zone.classList.add('ppd-zone-active');
            });
            zone.addEventListener('dragleave', function (e) { if (e.target === zone) zone.classList.remove('ppd-zone-active'); });
            zone.addEventListener('drop', function (e) {
                if (overChild(e)) return;
                e.preventDefault(); e.stopPropagation();
                zone.classList.remove('ppd-zone-active');
                var zid = zone.getAttribute('data-ppd-zone');
                var parentId = zid === '__root__' ? null : zid;
                var tabAttr = zone.getAttribute('data-ppd-tab');
                var tab = tabAttr != null ? (parseInt(tabAttr, 10) || 0) : null;
                var paletteType = e.dataTransfer.getData('text/ppd-type');
                var fieldName = e.dataTransfer.getData('text/ppd-field');
                if (paletteType) {
                    var nid = addFromPalette(paletteType, fieldName, { parentId: parentId, tab: tab });
                    store.setUi({ selectedId: nid });
                } else if (dragId) {
                    // move into this container (append at end)
                    moveIntoContainer(dragId, parentId);
                    if (tab != null) model.setTab(dragId, tab);
                    store.setUi({ selectedId: dragId });
                }
                dragId = null;
            });
        });
    }

    function moveIntoContainer(id, parentId) {
        var page = model.activePage(); if (!page) return;
        if (!parentId) { // move to root end
            var list = page.components;
            if (list.length) model.move(id, list[list.length - 1].id, 'after');
            return;
        }
        var parent = model.find(page, parentId);
        if (!parent || !parent.node) return;
        var kids = parent.node.children || [];
        if (kids.length) model.move(id, kids[kids.length - 1].id, 'after');
        else {
            // empty container: remove from current list, push as first child
            store.mutate(function () {
                var src = model.find(model.activePage(), id); if (!src) return;
                var i = src.list.indexOf(src.node); if (i > -1) src.list.splice(i, 1);
                var p = model.find(model.activePage(), parentId); if (!p) return;
                p.node.children = p.node.children || [];
                p.node.children.push(src.node);
            });
        }
    }

    function clearMarks(canvas) {
        qsa('.ppd-drop-before,.ppd-drop-after,.ppd-zone-active', canvas).forEach(function (e) { e.classList.remove('ppd-drop-before', 'ppd-drop-after', 'ppd-zone-active'); });
    }

    PPD.renderer = { render: render, reorderColumn: reorderColumn, addColumnAt: addColumnAt };
})();
