/* ============================================================
   PPphp Style SPA — theme designer
   Edit design tokens (light + dark) with a live preview.
   ============================================================ */
(function () {
    'use strict';
    var boot = window.PpStyleBootstrap || {};
    var base = (boot.baseUrl || '').replace(/\/+$/, '');
    var slug = boot.slug || '';

    var VARMAP = {
        bg: '--pp-bg', surface: '--pp-surface', surfaceSoft: '--pp-surface-soft',
        text: '--pp-text', muted: '--pp-muted', border: '--pp-border',
        primary: '--pp-primary', primarySoft: '--pp-primary-soft', primaryText: '--pp-primary-text',
        success: '--pp-success', warning: '--pp-warning', danger: '--pp-danger',
        radius: '--pp-radius', shadow: '--pp-shadow', font: '--pp-font', mono: '--pp-mono'
    };
    var GROUPS = [
        { label: 'Surfaces', keys: ['bg', 'surface', 'surfaceSoft', 'border'] },
        { label: 'Text', keys: ['text', 'muted'] },
        { label: 'Brand', keys: ['primary', 'primarySoft', 'primaryText'] },
        { label: 'Status', keys: ['success', 'warning', 'danger'] },
        { label: 'Shape & type', keys: ['radius', 'shadow', 'font', 'mono'] }
    ];
    var LABELS = {
        bg: 'Background', surface: 'Surface', surfaceSoft: 'Surface soft', border: 'Border',
        text: 'Text', muted: 'Muted', primary: 'Primary', primarySoft: 'Primary soft',
        primaryText: 'On primary', success: 'Success', warning: 'Warning', danger: 'Danger',
        radius: 'Radius', shadow: 'Shadow', font: 'Font', mono: 'Mono font'
    };
    var COLOR_KEYS = ['bg', 'surface', 'surfaceSoft', 'text', 'muted', 'border', 'primary', 'primarySoft', 'primaryText', 'success', 'warning', 'danger'];
    // also drive the designer's --ppd-* component tokens so real pages re-theme
    var ALIAS = {
        bg: ['--ppd-bg'], surface: ['--ppd-surface'], surfaceSoft: ['--ppd-soft'],
        text: ['--ppd-text'], muted: ['--ppd-muted'], border: ['--ppd-border', '--ppd-border-soft'],
        primary: ['--ppd-primary'], primarySoft: ['--ppd-primary-soft', '--ppd-primary-border'],
        danger: ['--ppd-danger'], success: ['--ppd-ok'], shadow: ['--ppd-shadow']
    };

    var S = { themes: [], byId: {}, current: null, mode: 'light', def: boot.default || 'default', dirty: false, src: 'sample', srcKind: '', srcId: '', table: '', pageType: '', device: 'desktop', tab: 'theme', menu: (boot.menu && boot.menu.items) ? boot.menu : { items: [] }, projectShell: boot.projectShell || null };

    // Layout (shell) settings live at PROJECT level so switching themes never
    // resets them; they are persisted separately and overlaid on every theme.
    var shellSaveT;
    function saveShellSoon() {
        clearTimeout(shellSaveT);
        shellSaveT = setTimeout(function () { post('/save', { shell: S.projectShell }); }, 600);
    }
    var TPL_LABELS = { list: 'List', add: 'Add', edit: 'Edit', view: 'View', print: 'Print', pdf: 'PDF', cards: 'Cards', import: 'Import', export: 'Export' };
    var COMMON_LABELS = { menu: 'Menu (Welcome)', login: 'Login', register: 'Register', profile: 'Profile', userinfo: 'User info', change_password: 'Change password', reset_password: 'Reset password', change_password_success: 'Password changed', reset_password_success: 'Reset sent', central_dashboard: 'Dashboard', access_denied: 'Access denied' };
    function cap(s) { return String(s || '').replace(/_/g, ' ').replace(/\b\w/g, function (c) { return c.toUpperCase(); }); }
    function setPath(o, path, v) { var p = path.split('.'); for (var i = 0; i < p.length - 1; i++) { if (o[p[i]] == null || typeof o[p[i]] !== 'object') o[p[i]] = {}; o = o[p[i]]; } o[p[p.length - 1]] = v; }
    function getPath(o, path) { var p = path.split('.'); for (var i = 0; i < p.length; i++) { if (o == null) return undefined; o = o[p[i]]; } return o; }
    var $ = function (id) { return document.getElementById(id); };
    function esc(v) { return String(v == null ? '' : v).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }
    function kebab(k) { return k.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); }
    function sanitize(v) { return String(v == null ? '' : v).replace(/[{};<>]|@import|expression\(/g, '').trim(); }

    /* ---- client theme compiler (mirror of core/Theme/ThemeCompiler) ---- */
    function block(sel, tokens) {
        var lines = [];
        Object.keys(tokens || {}).forEach(function (k) {
            var v = sanitize(tokens[k]); if (!v) return;
            lines.push('  ' + (VARMAP[k] || ('--pp-' + kebab(k))) + ': ' + v + ';');
            (ALIAS[k] || []).forEach(function (a) { lines.push('  ' + a + ': ' + v + ';'); });
            var m = k === 'radius' && /^(\d+(?:\.\d+)?)px$/.exec(v);
            if (m) { var n = parseFloat(m[1]); lines.push('  --pp-radius-sm: ' + Math.round(n * 6) / 10 + 'px;'); lines.push('  --pp-radius-lg: ' + Math.round(n * 16) / 10 + 'px;'); }
        });
        return lines.length ? sel + ' {\n' + lines.join('\n') + '\n}' : '';
    }
    var SHELL_PALETTES = {
        navy: { bg: '#0f172a', text: '#cbd5e1', active: '#ffffff', accent: '#3b82f6' },
        dark: { bg: '#1e293b', text: '#cbd5e1', active: '#ffffff', accent: '#60a5fa' },
        slate: { bg: '#334155', text: '#e2e8f0', active: '#ffffff', accent: '#93c5fd' },
        light: { bg: '#f1f5f9', text: '#475569', active: '#0f172a', accent: '#2563eb' },
        white: { bg: '#ffffff', text: '#475569', active: '#0f172a', accent: '#2563eb' },
        primary: { bg: 'var(--pp-primary)', text: '#ffffff', active: '#ffffff', accent: 'rgba(255,255,255,.85)' }
    };
    var DENSITY_PAD = { compact: '6px', normal: '10px', comfortable: '15px' };
    function shellBlock(scope, theme) {
        var sh = theme.shell || {}, grid = theme.grid || {};
        var pal = function (n) { return SHELL_PALETTES[n] || SHELL_PALETTES.navy; };
        var top = pal((sh.topbar || {}).theme || 'navy'), side = pal((sh.sidebar || {}).theme || 'navy'), logo = pal((sh.logo || {}).theme || (sh.topbar || {}).theme || 'navy');
        var L = [
            '--pp-topbar-bg: ' + top.bg, '--pp-topbar-text: ' + top.text, '--pp-topbar-accent: ' + top.accent,
            '--pp-topbar-h: ' + sanitize((sh.topbar || {}).height || '56px'),
            '--pp-sidebar-bg: ' + side.bg, '--pp-sidebar-text: ' + side.text, '--pp-sidebar-active: ' + side.active, '--pp-sidebar-accent: ' + side.accent,
            '--pp-sidebar-w: ' + sanitize((sh.sidebar || {}).width || '250px'),
            '--pp-logo-bg: ' + logo.bg, '--pp-logo-text: ' + logo.text,
            '--pp-density-pad: ' + (DENSITY_PAD[theme.density || 'normal'] || '10px'),
            '--pp-grid-panel: ' + sanitize(grid.panelColor || '#ffffff'), '--pp-grid-bg: ' + sanitize(grid.gridColor || '#ffffff')
        ];
        return scope + ' {\n  ' + L.join(';\n  ') + ';\n}';
    }
    // Baseline dark palette merged UNDER a theme's own dark tokens (mirror of
    // ThemeCompiler::DEFAULT_DARK) so partial/empty dark maps still render a
    // complete dark mode. Brand/status tokens keep the theme's light values.
    var DEFAULT_DARK = {
        bg: '#0f172a', surface: '#1e293b', surfaceSoft: '#26334a',
        text: '#e2e8f0', muted: '#94a3b8', border: '#334155',
        primarySoft: 'rgba(96,165,250,0.16)', shadow: '0 12px 32px rgba(0,0,0,0.45)'
    };
    function darkTokens(theme) {
        var out = {}, k, t = (theme && theme.dark) || {};
        for (k in DEFAULT_DARK) out[k] = DEFAULT_DARK[k];
        for (k in t) { if (String(t[k] == null ? '' : t[k]).trim() !== '') out[k] = t[k]; }
        return out;
    }
    function compile(theme, scope) {
        var light = block(scope, theme.tokens || {});
        var shell = shellBlock(scope, theme);
        // Always emit dark: theme dark tokens over the baseline, plus grid
        // surfaces rebound to the dark surface (Grid tab colours are
        // light-mode values — without this, tables keep light rows).
        var dark = block(scope + '.is-dark', darkTokens(theme))
            + '\n' + scope + '.is-dark {\n  --pp-grid-panel: var(--pp-surface);\n  --pp-grid-bg: var(--pp-surface);\n}';
        return light + '\n' + shell + '\n' + dark + glassBlock(scope, theme);
    }
    // Mirrors core/Theme/ThemeCompiler::glassBlock() for the live preview. The
    // build uses the PHP compiler (authoritative); this keeps the designer honest.
    function glassBlock(scope, theme) {
        if ((theme && theme.surface) !== 'glass') return '';
        var s = scope || ':root';
        var sel = function (arr) { return arr.map(function (x) { return s + ' ' + x; }).join(', '); };
        var surfaces = ['.card', '.dropdown-menu', '.modal-content', '.toast', '.list-group', '.offcanvas', '.pp-card', '.pp-mc', '.pp-master-card', '.pp-record-card', '.pp-detail-grid', '.pp-menu-page .ppd-stat', '.ppd-dashboard-widget', '.ppd-shell-main section', '.pp-form-body', '.pp-form-modal-frame', '.pp-form-modal-dialog', '.pp-quick', '.pp-fold', '.pp-dd', '.pp-act-pop', '.pp-cols-menu', '.pp-filter-menu', '.pp-export-menu', '.pp-notif-menu', '.pp-notif-toast', '.pp-notif-item', '.pp-vsummary', '.pp-prof-card', '.pp-pwd-card', '.pp2fa-form', '.ppd-grid-table-wrap', '.ppd-grid-pagination', '.pp-pg', '.pp-list-footer', '.ppd-grid-empty', '.ppcal-callist', '.ppcal-cats', '.ppcal-agenda', '.ppcal-mini'];
        var inputs = ['.form-control', '.form-select', '.input-group-text', '.ppd-grid-search', '.pp-input', '.pp-fld', '.pp-inline-input', '.ppcal-input'];
        var buttons = ['.btn', '.pp-btn-form', '.pp-mc-btn', '.pp-b2', '.ppd-grid-toolbar-btn', '.pp-inline-save', '.pp-act-more', '.ppcal-btn'];
        var buttonsSoft = ['.btn:not(.btn-primary):not([class*="btn-outline"])', '.pp-mc-btn', '.pp-b2', '.ppd-grid-toolbar-btn', '.ppcal-btn:not(.ppcal-primary)'];
        var refractCls = ['.card', '.modal-content', '.pp-card', '.pp-mc', '.pp-master-card', '.pp-record-card', '.pp-menu-page .ppd-stat', '.pp-hero', '.ppd-grid-table-wrap', '.btn-primary', '.ppcal-primary'];
        var clear = ['.pp-shell', '.ppd-shell-main', '.pp-main', '.pp-menu-page', '.pp-content', '.pp-list-wrap'];
        var shell = ['.pp-sidebar', '.ppd-shell-sidebar', '.pp-topbar'];
        var tableClear = ['.ppd-grid-table', '.ppd-grid-table tbody tr', '.ppd-grid-table td', '.ppd-grid-table.pp-striped tbody tr:nth-child(odd)'];
        var tableHead = ['.ppd-grid-table thead th', '.ppd-grid-table.pp-sticky thead th'];
        var tableHover = ['.ppd-grid-table.pp-hover tbody tr:hover'];
        var surf = sel(surfaces), inp = sel(inputs), btn = sel(buttons), btnSoft = sel(buttonsSoft), refract = sel(refractCls);
        var clearSel = sel(clear), shellSel = sel(shell), tableClearSel = sel(tableClear), tableHeadSel = sel(tableHead), tableHoverSel = sel(tableHover);
        var refractAfter = refractCls.map(function (x) { return s + ' ' + x + '::after'; }).join(', ');
        var liquidCards = ['.ppg-form > .pp-form-body', '.pp-record-card', '.pp-master-card', '.pp-vsummary', '.pp-prof-card', '.pp-pwd-card', '.pp2fa-form'];
        var liqSel = liquidCards.map(function (x) { return s + ' ' + x; }).join(', ');
        var liqBefore = liquidCards.map(function (x) { return s + ' ' + x + '::before'; }).join(', ');
        var liqAfter = liquidCards.map(function (x) { return s + ' ' + x + '::after'; }).join(', ');
        var liqDarkSel = liquidCards.map(function (x) { return s + '.is-dark ' + x; }).join(', ');
        var liqDarkBefore = liquidCards.map(function (x) { return s + '.is-dark ' + x + '::before'; }).join(', ');
        var aurora = 'radial-gradient(42% 48% at 16% 8%,color-mix(in srgb,var(--pp-primary) 62%,transparent),transparent 60%),radial-gradient(38% 44% at 88% 4%,rgba(56,207,255,.5),transparent 58%),radial-gradient(48% 54% at 90% 88%,rgba(168,110,255,.5),transparent 60%),radial-gradient(42% 48% at 6% 94%,rgba(255,110,180,.42),transparent 58%),radial-gradient(60% 66% at 48% 46%,color-mix(in srgb,var(--pp-primary) 26%,transparent),transparent 72%)';
        var auroraDark = 'radial-gradient(44% 50% at 16% 8%,color-mix(in srgb,var(--pp-primary) 82%,transparent),transparent 58%),radial-gradient(40% 46% at 88% 4%,rgba(56,207,255,.36),transparent 56%),radial-gradient(50% 56% at 90% 88%,rgba(150,90,255,.46),transparent 58%),radial-gradient(44% 50% at 6% 94%,rgba(255,90,170,.32),transparent 56%),radial-gradient(64% 70% at 48% 46%,color-mix(in srgb,var(--pp-primary) 34%,transparent),transparent 72%)';
        return '\n' + s + ' {\n'
            + '  --pp-glass-blur:22px; --pp-glass-refract:linear-gradient(100deg,#ff5db4,#a07bff,#37cfff,#5dffa8);\n'
            + '  --pp-glass-face:linear-gradient(135deg,rgba(255,255,255,.46) 0%,rgba(255,255,255,.18) 46%,rgba(255,255,255,.08) 100%);\n'
            + '  --pp-glass-soft:linear-gradient(135deg,rgba(255,255,255,.32),rgba(255,255,255,.10));\n'
            + '  --pp-glass-edge:rgba(255,255,255,.44); --pp-glass-edge-soft:rgba(255,255,255,.26);\n'
            + '  --pp-glass-shell:color-mix(in srgb,var(--pp-primary) 58%,transparent); --pp-glass-hero:color-mix(in srgb,var(--pp-primary) 64%,transparent);\n'
            + '  --pp-glass-gloss:inset 0 1px 0 rgba(255,255,255,.6),inset 0 -18px 28px rgba(255,255,255,.07);\n'
            + '  --pp-glass-shadow:0 14px 38px rgba(23,26,64,.15);\n}\n'
            + s + '.is-dark {\n'
            + '  --pp-glass-face:linear-gradient(135deg,rgba(255,255,255,.17) 0%,rgba(255,255,255,.06) 46%,rgba(255,255,255,.03) 100%);\n'
            + '  --pp-glass-soft:linear-gradient(135deg,rgba(255,255,255,.12),rgba(255,255,255,.03));\n'
            + '  --pp-glass-edge:rgba(255,255,255,.15); --pp-glass-edge-soft:rgba(255,255,255,.10);\n'
            + '  --pp-glass-shell:color-mix(in srgb,var(--pp-primary) 46%,rgba(10,12,24,.55)); --pp-glass-hero:color-mix(in srgb,var(--pp-primary) 52%,rgba(10,12,24,.4));\n'
            + '  --pp-glass-gloss:inset 0 1px 0 rgba(255,255,255,.26),inset 0 -18px 28px rgba(255,255,255,.04);\n'
            + '  --pp-glass-shadow:0 16px 42px rgba(0,0,0,.42);\n}\n'
            + s + ' body {\n  background-color:var(--pp-bg);\n  background-image:' + aurora + ';\n  background-attachment:fixed;\n}\n'
            + s + '.is-dark body {\n  background-image:' + auroraDark + ';\n}\n'
            + clearSel + ' { background:transparent !important; background-image:none !important; }\n'
            + shellSel + ' {\n  background:var(--pp-glass-shell) !important;\n  -webkit-backdrop-filter:blur(24px) saturate(160%);\n  backdrop-filter:blur(24px) saturate(160%);\n  border-color:var(--pp-glass-edge) !important;\n}\n'
            + s + ' .pp-footer { background:var(--pp-glass-soft); border-top:1px solid var(--pp-glass-edge); -webkit-backdrop-filter:blur(16px); backdrop-filter:blur(16px); }\n'
            + surf + ' {\n  background:var(--pp-glass-face);\n  -webkit-backdrop-filter:blur(var(--pp-glass-blur)) saturate(190%);\n  backdrop-filter:blur(var(--pp-glass-blur)) saturate(190%);\n  border:1px solid var(--pp-glass-edge);\n  box-shadow:var(--pp-glass-shadow),var(--pp-glass-gloss);\n}\n'
            + s + ' .pp-hero {\n  background:var(--pp-glass-hero) !important;\n  -webkit-backdrop-filter:blur(20px) saturate(170%);\n  backdrop-filter:blur(20px) saturate(170%);\n  border:1px solid var(--pp-glass-edge);\n  box-shadow:var(--pp-glass-shadow),var(--pp-glass-gloss);\n}\n'
            + tableClearSel + ' { background:transparent !important; }\n'
            + tableHeadSel + ' { background:color-mix(in srgb,var(--pp-primary) 16%,transparent) !important; -webkit-backdrop-filter:blur(8px); backdrop-filter:blur(8px); }\n'
            + tableHoverSel + ' { background:color-mix(in srgb,var(--pp-primary) 14%,transparent) !important; }\n'
            + btn + ' { -webkit-backdrop-filter:blur(12px) saturate(175%); backdrop-filter:blur(12px) saturate(175%); border:1px solid var(--pp-glass-edge-soft); box-shadow:inset 0 1px 0 rgba(255,255,255,.4); }\n'
            + btnSoft + ' { background:var(--pp-glass-soft); }\n'
            + s + ' .ppd-grid-toolbar { gap:8px; }\n'
            + s + ' .btn-primary, ' + s + ' .pp-btn-form, ' + s + ' .ppcal-primary, ' + s + ' .ppa-btn-primary, ' + s + ' .pp-prof-save, ' + s + ' .pp-pwd-save, ' + s + ' .pp2fa-act { position:relative; overflow:hidden; background:var(--pp-primary) !important; color:var(--pp-primary-text) !important; border-color:color-mix(in srgb,var(--pp-primary) 60%,transparent) !important; box-shadow:var(--pp-glass-shadow),inset 0 1px 0 rgba(255,255,255,.55); }\n'
            + s + ' .btn-primary::before, ' + s + ' .pp-btn-form::before, ' + s + ' .ppcal-primary::before, ' + s + ' .ppa-btn-primary::before, ' + s + ' .pp-prof-save::before, ' + s + ' .pp-pwd-save::before, ' + s + ' .pp2fa-act::before { content:""; position:absolute; left:0; right:0; top:0; height:50%; background:linear-gradient(180deg,rgba(255,255,255,.45),transparent); pointer-events:none; }\n'
            + inp + ' { background:var(--pp-glass-soft); -webkit-backdrop-filter:blur(10px) saturate(165%); backdrop-filter:blur(10px) saturate(165%); border:1px solid var(--pp-glass-edge-soft); box-shadow:inset 0 1px 3px rgba(15,23,42,.07),inset 0 1px 0 rgba(255,255,255,.35); }\n'
            + s + ' .is-float-outline .pp-input, ' + s + ' .is-float-filled .pp-input { background:var(--pp-glass-soft) !important; -webkit-backdrop-filter:blur(10px) saturate(165%); backdrop-filter:blur(10px) saturate(165%); border:1px solid var(--pp-glass-edge-soft) !important; box-shadow:inset 0 1px 3px rgba(15,23,42,.07),inset 0 1px 0 rgba(255,255,255,.35); }\n'
            + s + ' .is-float-outline .pp-input:focus ~ label, ' + s + ' .pp-fld.is-float-outline .pp-input:not(:placeholder-shown) ~ label { background:linear-gradient(135deg,color-mix(in srgb,var(--pp-surface) 40%,transparent) 0%,color-mix(in srgb,var(--pp-primary) 20%,transparent) 100%); -webkit-backdrop-filter:blur(7px) saturate(170%); backdrop-filter:blur(7px) saturate(170%); border-radius:8px; padding:0 .45rem; box-shadow:inset 0 1px 0 rgba(255,255,255,.5),0 2px 7px color-mix(in srgb,var(--pp-primary) 16%,transparent); }\n'
            + s + '.is-dark .is-float-outline .pp-input:focus ~ label, ' + s + '.is-dark .pp-fld.is-float-outline .pp-input:not(:placeholder-shown) ~ label { box-shadow:inset 0 1px 0 rgba(255,255,255,.16),0 2px 7px rgba(0,0,0,.32); }\n'
            + s + ' #pp-calendar-app, ' + s + ' .ppcal-app, ' + s + ' .pp-calapp, ' + s + ' .ppcal-grid-scroll { background:transparent !important; border:0 !important; box-shadow:none !important; -webkit-backdrop-filter:none !important; backdrop-filter:none !important; }\n'
            + s + ' .ppcal-grid { background:color-mix(in srgb,var(--pp-primary) 12%,transparent) !important; border-radius:14px; overflow:hidden; }\n'
            + s + ' .ppcal-cell { background:var(--pp-glass-soft) !important; -webkit-backdrop-filter:blur(12px) saturate(170%); backdrop-filter:blur(12px) saturate(170%); border-color:transparent !important; }\n'
            + s + ' .ppcal-cell.is-weekend { background:color-mix(in srgb,var(--pp-primary) 12%,var(--pp-glass-soft)) !important; }\n'
            + s + ' .ppcal-views { background:var(--pp-glass-soft) !important; border:1px solid var(--pp-glass-edge) !important; -webkit-backdrop-filter:blur(14px) saturate(170%); backdrop-filter:blur(14px) saturate(170%); box-shadow:var(--pp-glass-gloss); }\n'
            + s + ' .ppcal-vw { color:var(--pp-text); border-radius:8px; transition:background .14s,color .14s; }\n'
            + s + ' .ppcal-vw:hover { background:color-mix(in srgb,var(--pp-surface) 40%,transparent); }\n'
            + s + ' .ppcal-vw.is-active { background:color-mix(in srgb,var(--pp-primary) 22%,transparent) !important; color:var(--pp-primary) !important; box-shadow:inset 0 1px 0 rgba(255,255,255,.45),0 2px 7px color-mix(in srgb,var(--pp-primary) 22%,transparent) !important; }\n'
            + s + ' .ppcal-callist, ' + s + ' .ppcal-cats { background:color-mix(in srgb,var(--pp-surface) 26%,transparent) !important; border:1px solid var(--pp-glass-edge-soft) !important; box-shadow:inset 0 1px 0 rgba(255,255,255,.32) !important; -webkit-backdrop-filter:blur(12px) saturate(150%); backdrop-filter:blur(12px) saturate(150%); border-radius:12px; padding:6px; }\n'
            + s + ' .ppcal-calrow:hover, ' + s + ' .ppcal-catrow:hover { background:color-mix(in srgb,var(--pp-primary) 12%,transparent) !important; }\n'
            + s + ' .ppd-grid-toolbar { background:transparent !important; border:0 !important; box-shadow:none !important; -webkit-backdrop-filter:none !important; backdrop-filter:none !important; }\n'
            + s + ' .pp-form-body .pp-form-body, ' + s + ' .ppg-form-section, ' + s + ' .ppg-form-grid, ' + s + ' .pp-lgcell, ' + s + ' .pp-zone12, ' + s + ' .pp-fld { background:transparent !important; border:0 !important; box-shadow:none !important; -webkit-backdrop-filter:none !important; backdrop-filter:none !important; }\n'
            + s + ' .pp-form-body { padding:1.5rem 1.65rem; }\n'
            + s + ' .pp-form-body .pp-form-body { padding:0 !important; }\n'
            + liqSel + ' { position:relative; border:0 !important; border-radius:calc(var(--pp-radius,16px) + 6px); overflow:visible !important; box-shadow:var(--pp-glass-shadow),0 30px 60px -28px color-mix(in srgb,var(--pp-primary) 30%,transparent),inset 0 1px 0 rgba(255,255,255,.6),inset 0 -24px 44px rgba(255,255,255,.05); }\n'
            + liqBefore + ' { content:""; position:absolute; inset:0; border-radius:inherit; padding:1.5px; background:linear-gradient(135deg,rgba(255,255,255,.92) 0%,rgba(255,255,255,.28) 22%,color-mix(in srgb,var(--pp-primary) 34%,transparent) 48%,rgba(255,255,255,.12) 70%,rgba(255,255,255,.55) 100%); -webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0); -webkit-mask-composite:xor; mask-composite:exclude; pointer-events:none; z-index:3; }\n'
            + liqAfter + ' { content:""; position:absolute; left:7%; right:7%; bottom:-15px; height:30px; border-radius:50%; background:var(--pp-glass-refract); filter:blur(17px); opacity:.4; z-index:-1; pointer-events:none; }\n'
            + liqDarkSel + ' { box-shadow:var(--pp-glass-shadow),0 34px 64px -30px rgba(0,0,0,.6),inset 0 1px 0 rgba(255,255,255,.2),inset 0 -24px 44px rgba(255,255,255,.02); }\n'
            + liqDarkBefore + ' { background:linear-gradient(135deg,rgba(255,255,255,.5) 0%,rgba(255,255,255,.1) 24%,color-mix(in srgb,var(--pp-primary) 42%,transparent) 50%,rgba(255,255,255,.05) 72%,rgba(255,255,255,.28) 100%); }\n'
            + s + ' .pp-record-card-h { background:linear-gradient(120deg,color-mix(in srgb,var(--pp-primary) 24%,transparent),color-mix(in srgb,var(--pp-primary) 8%,transparent)) !important; border-bottom:1px solid var(--pp-glass-edge-soft) !important; border-radius:calc(var(--pp-radius,16px) + 6px) calc(var(--pp-radius,16px) + 6px) 0 0; box-shadow:inset 0 1px 0 rgba(255,255,255,.45); }\n'
            + s + ' .pp-form-head { background:transparent; border:0; padding:0 0 .9rem; margin:0 0 1.15rem; border-bottom:1px solid var(--pp-glass-edge-soft); }\n'
            + s + ' .pp-input:focus, ' + s + ' .pp-fld-wrap:focus-within .pp-input, ' + s + ' .form-control:focus, ' + s + ' .form-select:focus { outline:none; border-color:color-mix(in srgb,var(--pp-primary) 55%,transparent) !important; box-shadow:0 0 0 3px color-mix(in srgb,var(--pp-primary) 20%,transparent),inset 0 1px 3px rgba(15,23,42,.06); }\n'
            + s + ' .pp-form-actions { background:transparent !important; border:0 !important; border-top:1px solid var(--pp-glass-edge-soft) !important; border-radius:0 !important; box-shadow:none !important; -webkit-backdrop-filter:none !important; backdrop-filter:none !important; margin:1.25rem 0 0 !important; padding:1.1rem .1rem 0 !important; }\n'
            + s + ' .pp-master-card, ' + s + ' .pp-detail-grid, ' + s + ' .pp-vsummary { margin-top:1.4rem; }\n'
            + s + ' .pp-list-footer { border:0 !important; box-shadow:var(--pp-glass-shadow) !important; border-radius:18px; padding:12px 22px !important; }\n'
            + s + ' .ppd-grid-pagination, ' + s + ' .pp-pg { border-radius:12px; }\n'
            + s + ' .pp-pg { background:var(--pp-glass-soft) !important; border:1px solid var(--pp-glass-edge-soft) !important; -webkit-backdrop-filter:blur(10px) saturate(160%); backdrop-filter:blur(10px) saturate(160%); box-shadow:inset 0 1px 0 rgba(255,255,255,.35); }\n'
            + s + ' .pp-pg:hover { border-color:color-mix(in srgb,var(--pp-primary) 45%,transparent) !important; color:var(--pp-primary) !important; }\n'
            + s + ' .pp-pg.active { background:var(--pp-primary) !important; border-color:color-mix(in srgb,var(--pp-primary) 60%,transparent) !important; color:var(--pp-primary-text) !important; box-shadow:var(--pp-glass-shadow),inset 0 1px 0 rgba(255,255,255,.5); }\n'
            + s + ' .pp-pg.gap { background:transparent !important; border:0 !important; -webkit-backdrop-filter:none !important; backdrop-filter:none !important; box-shadow:none !important; }\n'
            + s + ' .pp-chip { background:color-mix(in srgb,var(--pp-primary) 20%,transparent) !important; -webkit-backdrop-filter:blur(8px) saturate(160%); backdrop-filter:blur(8px) saturate(160%); box-shadow:inset 0 1px 0 rgba(255,255,255,.3); }\n'
            + s + ' .pp-filter-chips { background:color-mix(in srgb,var(--pp-surface) 22%,transparent) !important; border:1px solid var(--pp-glass-edge-soft) !important; -webkit-backdrop-filter:blur(10px) saturate(150%); backdrop-filter:blur(10px) saturate(150%); }\n'
            + s + ' .ppa-card, ' + s + ' .ppa-gcard, ' + s + ' .ppa-guide { background:var(--pp-glass-face) !important; border:1px solid var(--pp-glass-edge) !important; -webkit-backdrop-filter:blur(var(--pp-glass-blur)) saturate(190%); backdrop-filter:blur(var(--pp-glass-blur)) saturate(190%); box-shadow:var(--pp-glass-shadow),var(--pp-glass-gloss) !important; }\n'
            + s + ' .ppa-toolbar { background:transparent !important; border-bottom:1px solid var(--pp-glass-edge-soft) !important; }\n'
            + s + ' .ppa-btn { background:var(--pp-glass-soft) !important; border:1px solid var(--pp-glass-edge-soft) !important; -webkit-backdrop-filter:blur(10px) saturate(160%); backdrop-filter:blur(10px) saturate(160%); box-shadow:inset 0 1px 0 rgba(255,255,255,.35); }\n'
            + s + ' .ppa-btn:hover { border-color:color-mix(in srgb,var(--pp-primary) 45%,transparent) !important; background:color-mix(in srgb,var(--pp-primary) 12%,var(--pp-glass-soft)) !important; }\n'
            + s + ' .ppa-toolbar input[type=text] { background:var(--pp-glass-soft) !important; border:1px solid var(--pp-glass-edge-soft) !important; -webkit-backdrop-filter:blur(8px); backdrop-filter:blur(8px); }\n'
            + s + ' .ppa-tabs { border-bottom-color:var(--pp-glass-edge-soft) !important; }\n'
            + s + ' .ppa-table th { background:color-mix(in srgb,var(--pp-primary) 12%,transparent) !important; -webkit-backdrop-filter:blur(8px); backdrop-filter:blur(8px); }\n'
            + s + ' .ppa-table tbody tr:hover td { background:color-mix(in srgb,var(--pp-primary) 10%,transparent) !important; }\n'
            + s + ' .ppa-chip { background:color-mix(in srgb,var(--pp-primary) 14%,transparent) !important; }\n'
            + s + ' .ppa-matrix thead th { background:color-mix(in srgb,var(--pp-primary) 14%,var(--pp-surface)) !important; }\n'
            + s + ' .ppa-gitem { background:var(--pp-glass-soft) !important; border:1px solid var(--pp-glass-edge-soft) !important; -webkit-backdrop-filter:blur(8px); backdrop-filter:blur(8px); }\n'
            + s + ' .ppa-gitem.is-active, ' + s + ' .ppa-gitem:hover { background:color-mix(in srgb,var(--pp-primary) 16%,transparent) !important; border-color:color-mix(in srgb,var(--pp-primary) 40%,transparent) !important; }\n'
            + s + ' .ppal-scroll { background:var(--pp-glass-face) !important; border:1px solid var(--pp-glass-edge) !important; -webkit-backdrop-filter:blur(var(--pp-glass-blur)) saturate(190%); backdrop-filter:blur(var(--pp-glass-blur)) saturate(190%); box-shadow:var(--pp-glass-shadow),var(--pp-glass-gloss) !important; }\n'
            + s + ' .ppal-table thead th { background:color-mix(in srgb,var(--pp-primary) 12%,transparent) !important; -webkit-backdrop-filter:blur(8px); backdrop-filter:blur(8px); }\n'
            + s + ' .ppal-table tbody tr:hover { background:color-mix(in srgb,var(--pp-primary) 9%,transparent) !important; }\n'
            + s + ' .ppal-diff th, ' + s + ' .ppal-diff td { border-color:var(--pp-glass-edge-soft) !important; }\n'
            + s + ' .pp2fa-setup, ' + s + ' .pp2fa-card { background:var(--pp-glass-face) !important; border:1px solid var(--pp-glass-edge) !important; -webkit-backdrop-filter:blur(var(--pp-glass-blur)) saturate(190%); backdrop-filter:blur(var(--pp-glass-blur)) saturate(190%); box-shadow:var(--pp-glass-shadow),var(--pp-glass-gloss) !important; }\n'
            + s + ' .pp2fa-toggle, ' + s + ' .pp2fa-mcard { background:var(--pp-glass-face) !important; -webkit-backdrop-filter:blur(14px) saturate(180%); backdrop-filter:blur(14px) saturate(180%); }\n'
            + s + ' .pp2fa-key, ' + s + ' .pp2fa-codes code, ' + s + ' .pp2fa-btn.is-ghost { background:var(--pp-glass-soft) !important; }\n'
            + s + ' .pp2fa-otp input { background:var(--pp-glass-soft) !important; border-color:var(--pp-glass-edge-soft) !important; -webkit-backdrop-filter:blur(8px); backdrop-filter:blur(8px); }\n'
            + s + ' .pp-prof-field input, ' + s + ' .pp-pwd-field input { background:var(--pp-glass-soft) !important; border:1px solid var(--pp-glass-edge-soft) !important; -webkit-backdrop-filter:blur(8px); backdrop-filter:blur(8px); }\n'
            + s + ' .pp-prof-row { background:var(--pp-glass-soft) !important; border:1px solid var(--pp-glass-edge-soft) !important; }\n'
            + s + ' .pp-pwd-reqs { background:color-mix(in srgb,var(--pp-surface) 24%,transparent) !important; border:1px solid var(--pp-glass-edge-soft) !important; }\n'
            + s + ' .pp2fa-auth { grid-template-columns:1fr !important; place-items:center; background-color:var(--pp-bg) !important; background-image:radial-gradient(42% 48% at 16% 8%,color-mix(in srgb,var(--pp-primary) 62%,transparent),transparent 60%),radial-gradient(38% 44% at 88% 4%,rgba(56,207,255,.5),transparent 58%),radial-gradient(48% 54% at 90% 88%,rgba(168,110,255,.5),transparent 60%),radial-gradient(42% 48% at 6% 94%,rgba(255,110,180,.42),transparent 58%),radial-gradient(60% 66% at 48% 46%,color-mix(in srgb,var(--pp-primary) 26%,transparent),transparent 72%) !important; }\n'
            + s + ' .pp2fa-auth .pp2fa-show { display:none !important; }\n'
            + s + ' .pp2fa-auth .pp2fa-zone { background:transparent !important; }\n'
            + s + ' .pp2fa-form { color:var(--pp-text) !important; }\n'
            + s + ' .pp2fa-field input { background:var(--pp-glass-soft) !important; border:1px solid var(--pp-glass-edge-soft) !important; -webkit-backdrop-filter:blur(8px); backdrop-filter:blur(8px); color:var(--pp-text) !important; }\n'
            + s + ' .pp-prof-card, ' + s + ' .pp-pwd-card, ' + s + ' .pp2fa-form { background:var(--pp-glass-face) !important; -webkit-backdrop-filter:blur(var(--pp-glass-blur)) saturate(190%) !important; backdrop-filter:blur(var(--pp-glass-blur)) saturate(190%) !important; }\n'
            + s + ' .pp-profile { --prof-accent:var(--pp-primary) !important; }\n'
            + s + ' .pp-pwdform { --pwd-accent:var(--pp-primary) !important; }\n'
            + s + ' .pp2fa-auth { --auth-accent:var(--pp-primary) !important; }\n'
            + s + ' .ppd-auth-page { --auth-accent:var(--pp-primary) !important; grid-template-columns:1fr !important; place-items:center !important; background-color:var(--pp-bg) !important; background-image:radial-gradient(42% 48% at 16% 8%,color-mix(in srgb,var(--pp-primary) 62%,transparent),transparent 60%),radial-gradient(38% 44% at 88% 4%,rgba(56,207,255,.5),transparent 58%),radial-gradient(48% 54% at 90% 88%,rgba(168,110,255,.5),transparent 60%),radial-gradient(42% 48% at 6% 94%,rgba(255,110,180,.42),transparent 58%),radial-gradient(60% 66% at 48% 46%,color-mix(in srgb,var(--pp-primary) 26%,transparent),transparent 72%) !important; }\n'
            + s + ' .ppd-auth-page::before, ' + s + ' .ppd-auth-page::after { display:none !important; }\n'
            + s + ' .ppd-auth-page .ppd-auth-showcase { display:none !important; }\n'
            + s + ' .ppd-auth-page .ppd-auth-card-zone { background:transparent !important; }\n'
            + s + ' .ppd-auth-page .ppd-auth-form { position:relative; border:0 !important; border-radius:26px !important; overflow:visible !important; background:linear-gradient(140deg,color-mix(in srgb,var(--pp-surface) 30%,transparent) 0%,color-mix(in srgb,var(--pp-surface) 13%,transparent) 55%,color-mix(in srgb,var(--pp-primary) 10%,transparent) 100%) !important; color:var(--pp-text) !important; -webkit-backdrop-filter:blur(34px) saturate(205%) !important; backdrop-filter:blur(34px) saturate(205%) !important; box-shadow:var(--pp-glass-shadow),0 42px 84px -30px color-mix(in srgb,var(--pp-primary) 34%,transparent),inset 0 1px 0 rgba(255,255,255,.55),inset 0 -30px 60px rgba(255,255,255,.05) !important; }\n'
            + s + ' .ppd-auth-page .ppd-auth-form::before { content:""; position:absolute; inset:0; border-radius:inherit; padding:1.5px; background:linear-gradient(135deg,rgba(255,255,255,.92) 0%,rgba(255,255,255,.28) 22%,color-mix(in srgb,var(--pp-primary) 34%,transparent) 48%,rgba(255,255,255,.12) 70%,rgba(255,255,255,.55) 100%); -webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0); -webkit-mask-composite:xor; mask-composite:exclude; pointer-events:none; }\n'
            + s + ' .ppd-auth-page .ppd-auth-form::after { content:""; position:absolute; left:8%; right:8%; bottom:-14px; height:26px; border-radius:50%; background:var(--pp-glass-refract); filter:blur(16px); opacity:.4; z-index:-1; pointer-events:none; }\n'
            + s + ' .ppd-auth-page .ppd-auth-form h2, ' + s + ' .ppd-auth-page .ppd-auth-subtitle, ' + s + ' .ppd-auth-page .ppd-auth-field { color:var(--pp-text) !important; }\n'
            + s + ' .ppd-auth-page .ppd-auth-subtitle, ' + s + ' .ppd-auth-page .ppd-auth-links, ' + s + ' .ppd-auth-page .ppd-auth-security, ' + s + ' .ppd-auth-page .ppd-auth-options, ' + s + ' .ppd-auth-page .ppd-auth-divider { color:var(--pp-muted) !important; }\n'
            + s + ' .ppd-auth-page a { color:var(--pp-primary) !important; }\n'
            + s + ' .ppd-auth-page .ppd-auth-field input::placeholder { color:var(--pp-muted) !important; opacity:1; }\n'
            + s + ' .ppd-auth-page .ppd-auth-form > .ppd-auth-brand { display:flex !important; }\n'
            + s + ' .ppd-auth-page .ppd-auth-field input { background:color-mix(in srgb,var(--pp-surface) 20%,transparent) !important; border:1px solid var(--pp-glass-edge) !important; color:var(--pp-text) !important; -webkit-backdrop-filter:blur(10px) saturate(160%); backdrop-filter:blur(10px) saturate(160%); box-shadow:inset 0 1px 0 rgba(255,255,255,.4),inset 0 1px 3px rgba(15,23,42,.05); }\n'
            + s + ' .ppd-auth-page .ppd-auth-field input:focus { border-color:color-mix(in srgb,var(--pp-primary) 55%,transparent) !important; box-shadow:0 0 0 3px color-mix(in srgb,var(--pp-primary) 20%,transparent),inset 0 1px 0 rgba(255,255,255,.4) !important; }\n'
            + s + ' .ppd-auth-page .ppd-auth-action-primary, ' + s + ' .ppd-auth-page .ppd-auth-action { position:relative; overflow:hidden; background:var(--pp-primary) !important; color:var(--pp-primary-text) !important; box-shadow:var(--pp-glass-shadow),inset 0 1px 0 rgba(255,255,255,.55) !important; }\n'
            + s + ' .ppd-auth-page .ppd-auth-action-primary::before { content:""; position:absolute; left:0; right:0; top:0; height:50%; background:linear-gradient(180deg,rgba(255,255,255,.45),transparent); pointer-events:none; }\n'
            + s + ' .ppd-auth-page .ppd-auth-social button { background:var(--pp-glass-soft) !important; border:1px solid var(--pp-glass-edge-soft) !important; }\n'
            + s + ' .pp-prof-side, ' + s + ' .pp-prof-main, ' + s + ' .pp-prof-cover-body { background:var(--pp-glass-face) !important; border:1px solid var(--pp-glass-edge-soft) !important; -webkit-backdrop-filter:blur(var(--pp-glass-blur)) saturate(190%) !important; backdrop-filter:blur(var(--pp-glass-blur)) saturate(190%) !important; }\n'
            + s + ' .pp-profile, ' + s + ' .pp-pwdform { background:transparent !important; color:var(--pp-text) !important; }\n'
            + s + ' .pp-profile::before, ' + s + ' .pp-profile::after, ' + s + ' .pp-pwdform::before, ' + s + ' .pp-pwdform::after { display:none !important; }\n'
            + s + ' .pp-profile .pp-prof-headline h2, ' + s + ' .pp-profile .pp-prof-id strong, ' + s + ' .pp-profile .pp-prof-row strong, ' + s + ' .pp-profile .pp-prof-field input, ' + s + ' .pp-pwdform .pp-pwd-head h2, ' + s + ' .pp-pwdform .pp-pwd-field input { color:var(--pp-text) !important; }\n'
            + s + ' .pp-profile .pp-prof-headline p, ' + s + ' .pp-profile .pp-prof-row span, ' + s + ' .pp-profile .pp-prof-field > span, ' + s + ' .pp-profile .pp-prof-field label, ' + s + ' .pp-pwdform .pp-pwd-head p, ' + s + ' .pp-pwdform .pp-pwd-reqs { color:var(--pp-muted) !important; }\n'
            + s + ' .pp-profile .pp-prof-field input, ' + s + ' .pp-pwdform .pp-pwd-field input { background:color-mix(in srgb,var(--pp-surface) 20%,transparent) !important; border:1px solid var(--pp-glass-edge) !important; -webkit-backdrop-filter:blur(10px) saturate(160%); backdrop-filter:blur(10px) saturate(160%); }\n'
            + s + ' .pp-profile .pp-prof-field input::placeholder, ' + s + ' .pp-pwdform .pp-pwd-field input::placeholder { color:var(--pp-muted) !important; opacity:1; }\n'
            + s + ' .pp-profile .pp-prof-role { background:color-mix(in srgb,var(--pp-primary) 18%,transparent) !important; color:var(--pp-primary) !important; }\n'
            + s + ' .pp-profile .pp-prof-link { color:var(--pp-primary) !important; }\n'
            + refract + ' { position:relative; }\n'
            + refractAfter
            + ' {\n  content:""; position:absolute; left:6%; right:6%; bottom:-9px; height:20px; border-radius:50%;\n  background:var(--pp-glass-refract); filter:blur(11px); opacity:.5; z-index:-1; pointer-events:none;\n}\n'
            + '@media (prefers-reduced-transparency: reduce) {\n  ' + surf + ', ' + btn + ', ' + inp + ' { background:var(--pp-surface) !important; -webkit-backdrop-filter:none !important; backdrop-filter:none !important; }\n  ' + shellSel + ' { background:var(--pp-primary) !important; -webkit-backdrop-filter:none !important; backdrop-filter:none !important; }\n}';
    }
    function styleEl() { var e = $('spPreviewStyle'); if (!e) { e = document.createElement('style'); e.id = 'spPreviewStyle'; document.head.appendChild(e); } return e; }

    /* ---- footer PHP-snippet preview ----
       The footer text is a PHP-enabled template compiled by the build:
       {expr} segments run as PHP (raw <?= ?> tags are normalized to the same
       form). The preview emulates the common case — date('…') — client-side;
       other expressions are shown literally so the user sees them as dynamic. */
    var MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
    var DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
    function phpDate(fmt) {
        var d = new Date();
        var pad = function (n) { return (n < 10 ? '0' : '') + n; };
        var map = {
            Y: d.getFullYear(), y: String(d.getFullYear()).slice(-2),
            m: pad(d.getMonth() + 1), n: d.getMonth() + 1,
            d: pad(d.getDate()), j: d.getDate(),
            F: MONTHS[d.getMonth()], M: MONTHS[d.getMonth()].slice(0, 3),
            l: DAYS[d.getDay()], D: DAYS[d.getDay()].slice(0, 3),
            H: pad(d.getHours()), G: d.getHours(),
            i: pad(d.getMinutes()), s: pad(d.getSeconds())
        };
        var out = '', esc = false;
        String(fmt).split('').forEach(function (c) {
            if (esc) { out += c; esc = false; }
            else if (c === '\\') { esc = true; }
            else { out += (map[c] !== undefined ? map[c] : c); }
        });
        return out;
    }
    function footerPreview(text) {
        text = String(text || '')
            .replace(/<\?=\s*([\s\S]*?)\s*\?>/g, '{$1}')
            .replace(/<\?php\s+echo\s+([\s\S]*?);?\s*\?>/g, '{$1}');
        return text.replace(/\{([^{}]+)\}/g, function (all, expr) {
            var m = /^date\(\s*['"]([^'"]+)['"]\s*\)$/.exec(expr.trim());
            return m ? phpDate(m[1]) : all;
        });
    }

    /* ---- real designed-page rendering (reuses the designer registry) ---- */
    var PREVIEW_CTX = null;
    function renderNode(node) {
        var reg = window.PPD && window.PPD.registry; if (!reg) return '';
        var d = reg.get(node.type); var inner = '';
        try { inner = d.render(node, PREVIEW_CTX); } catch (e) { inner = ''; }
        var css = '';
        if (node.props && node.props.spans) {
            var sp = node.props.spans[S.device] || node.props.spans.desktop || 12;
            css = 'grid-column: span ' + Math.max(1, Math.min(12, sp)) + ';';
        }
        return '<div class="ppd-node"' + (css ? ' style="' + css + '"' : '') + '>' + inner + '</div>';
    }
    function realPageHtml() {
        var reg = window.PPD && window.PPD.registry; if (!reg) return '';
        var page = null, table = '';
        if (S.srcKind === 'tpl') page = (boot.designerTemplates || {})[S.srcId];
        else if (S.srcKind === 'common') page = (boot.designerCommon || {})[S.srcId];
        else {
            table = S.table;
            var node = boot.designerPages && boot.designerPages[S.table] && boot.designerPages[S.table][S.pageType];
            page = node && node.pages && node.pages[node.defaultPage || Object.keys(node.pages)[0]];
        }
        if (!page) return '';
        window.PPD.renderMode = 'preview';
        PREVIEW_CTX = { fields: [], table: table, renderChildren: function (n, cls) {
            return '<div class="ppd-children-zone ' + (cls || '') + '">' + (n.children || []).map(renderNode).join('') + '</div>';
        } };
        return '<div class="ppd-page-body ppd-children-zone">' + (page.components || []).map(renderNode).join('') + '</div>';
    }

    /* ---- app-shell chrome around the page content ---- */
    var DEFAULT_MENU = [
        { label: 'Dashboard', icon: 'bi-speedometer2' },
        { label: 'Customers', icon: 'bi-people', type: 'submenu', children: [{ label: 'All customers', icon: 'bi-list-ul' }, { label: 'Segments', icon: 'bi-diagram-3' }] },
        { label: 'Orders', icon: 'bi-bag' }, { label: 'Reports', icon: 'bi-graph-up' }, { label: 'Settings', icon: 'bi-gear' }
    ];
    function iconHtml(cls) { if (!cls) return ''; return (window.PPD && window.PPD.registry) ? window.PPD.registry.icon(cls) : '<i class="bi bi-' + esc(cls) + '"></i>'; }
    function menuData() { return (S.menu && S.menu.items && S.menu.items.length) ? S.menu.items : DEFAULT_MENU; }
    var SAMPLE_MAIN = '' +
        '<div class="pv-card"><h3>Customer details</h3><p class="muted">A representative card rendered with this theme.</p>' +
        '<div class="pv-btns"><button class="pv-btn primary">Save changes</button><button class="pv-btn">Cancel</button><button class="pv-btn soft">Duplicate</button><button class="pv-btn danger">Delete</button></div>' +
        '<div class="pv-field"><label>Full name</label><input value="Jane Doe"></div>' +
        '<div class="pv-grid" data-pvgrid><table><thead><tr><th>Field</th><th>Value</th><th>Status</th></tr></thead><tbody>' +
        '<tr><td>Email</td><td><span class="chip">jane@ex.com</span></td><td><span class="pv-badge s">Active</span></td></tr>' +
        '<tr><td>Plan</td><td><span class="chip">pro</span></td><td><span class="pv-badge w">Trial</span></td></tr>' +
        '<tr><td>Balance</td><td><span class="chip">$1,240</span></td><td><span class="pv-badge d">Overdue</span></td></tr>' +
        '<tr><td>Country</td><td><span class="chip">US</span></td><td><span class="pv-badge s">Verified</span></td></tr>' +
        '</tbody></table></div></div>' +
        '<div class="pv-stat" style="margin-top:16px"><div class="s"><b>1,248</b><br><span>Records</span></div><div class="s"><b>96%</b><br><span>Uptime</span></div><div class="s"><b>$48k</b><br><span>Revenue</span></div></div>';

    function menuPreview() {
        var items = (S.menu && S.menu.items && S.menu.items.length) ? S.menu.items : DEFAULT_MENU;
        return sidebarMenu();
    }
    function sidebarMenu() {
        return menuData().map(function (it, i) {
            var ic = '<span class="mi-i">' + iconHtml(it.icon || 'bi-circle') + '</span>';
            var caret = (it.children && it.children.length) ? '<i class="bi bi-chevron-down" style="margin-left:auto;font-size:11px;opacity:.6"></i>' : '';
            var row = '<div class="pv-menu-item' + (i === 0 ? ' on' : '') + '">' + ic + '<span class="lbl">' + esc(it.label || 'Item') + '</span>' + caret + '</div>';
            var sub = (it.children && it.children.length) ? '<div class="pv-menu-sub">' + it.children.map(function (c) {
                return '<div class="pv-menu-item"><span class="mi-i">' + iconHtml(c.icon || 'bi-dot') + '</span><span class="lbl">' + esc(c.label || 'Item') + '</span></div>';
            }).join('') + '</div>' : '';
            return row + sub;
        }).join('');
    }
    function topbarMenu() {
        return '<nav class="pv-topnav">' + menuData().map(function (it, i) {
            var ic = it.icon ? iconHtml(it.icon) + ' ' : '';
            var caret = (it.children && it.children.length) ? ' <i class="bi bi-chevron-down" style="font-size:10px;opacity:.7"></i>' : '';
            return '<a class="pv-topnav-item' + (i === 0 ? ' on' : '') + '">' + ic + esc(it.label || 'Item') + caret + '</a>';
        }).join('') + '</nav>';
    }
    function shellHtml(mainHtml) {
        var sh = S.current.shell || {};
        var top = sh.topbar || {}, side = sh.sidebar || {}, logo = sh.logo || {}, menu = sh.menu || {}, foot = sh.footer || {};
        var navbar = sh.navbar || 'classic';
        var placement = menu.placement || 'sidebar';
        var brand = (logo.text || 'APP').toString();
        var brandHtml = '<div class="pv-brand"><span class="pv-logo">' + esc(brand.charAt(0).toUpperCase()) + '</span>' + esc(brand) + '</div>';
        var topMid = placement === 'topbar'
            ? topbarMenu()
            : '<i class="bi bi-list pv-tb-ico"></i><span class="pv-tb-crumb">Home / Customers</span>';
        // Mirror exactly what the build emits, so the preview is a promise the
        // generated app keeps: gradient, edge, frosted and shrink all show here.
        var topFixed = top.fixed !== false;
        var topCls = 'pv-topbar'
            + (top.gradient ? ' grad' : '')
            + ' elev-' + (['shadow', 'border', 'none'].indexOf(top.elevation) !== -1 ? top.elevation : 'shadow')
            + (topFixed && top.blur ? ' blur' : '')
            + (topFixed && top.shrink ? ' shrink' : '');
        var topbar = top.enabled === false ? '' :
            '<header class="' + topCls + '">' + brandHtml + topMid +
                (top.search !== false
                    ? '<div class="pv-tb-search"><i class="bi bi-search"></i><span>Search…</span><kbd>/</kbd></div>'
                    : '') +
                (top.user !== false ? '<div class="pv-tb-user"><i class="bi bi-gear"></i><i class="bi bi-person-circle"></i> Admin</div>' : '') +
            '</header>';
        var showSidebar = side.enabled !== false && placement === 'sidebar';
        var sidebar = showSidebar ?
            '<aside class="pv-sidebar' + (side.compact ? ' compact' : '')
              + (side.fixed === false ? ' static' : '')
              + (side.autoHide ? ' autohide' : '')
              + ' pv-menu-style-' + (menu.style || 'rounded') + '">' + sidebarMenu() + '</aside>' : '';
        var fAlign = ({ left: 'left', center: 'center', right: 'right' })[foot.align] || 'left';
        var footer = foot.enabled !== false ? '<footer class="pv-footer" style="text-align:' + fAlign + '">' + esc(footerPreview(foot.text || '© Company')) + '</footer>' : '';
        return '<div class="pv-shell nb-' + esc(navbar) + '">' + topbar +
            '<div class="pv-shell-body' + (side.position === 'right' ? ' rtl' : '') + '">' + sidebar +
            '<main class="pv-main">' + mainHtml + '</main></div>' + footer + '</div>';
    }
    // apply the grid appearance flags to any preview grid
    function applyGridClasses() {
        var g = S.current.grid || {};
        [].forEach.call(document.querySelectorAll('#spPreview [data-pvgrid], #spPreview .ppd-grid, #spPreview .pv-grid'), function (el) {
            el.classList.add('pv-grid');
            el.classList.toggle('panel', !!g.panel);
            el.classList.toggle('vborders', g.verticalBorders !== false);
            el.classList.toggle('zebra', !!g.zebra);
            el.classList.toggle('condensed', !!g.condensed);
            el.classList.toggle('rollover', g.rollover !== false);
        });
    }
    // Scope a theme's raw `css` field (which targets the REAL generated app) into
    // the preview so the preview is faithful. Two things matter:
    //   • `:root` must map to the preview root ELEMENT (`#spPreview`) — the same
    //     scope compile() uses — not `#spPreview :root` (which matches nothing),
    //     otherwise shell re-points like `--pp-topbar-bg:var(--pp-surface)` are lost
    //     and the navbar/sidebar fall back to the raw SHELL_PALETTES (navy/gradient).
    //   • the real shell classes (`.pp-topbar` / `.pp-sidebar`) must bridge to the
    //     preview's mock classes (`.pv-topbar` / `.pv-sidebar`) so rules like
    //     `.pp-topbar.has-grad{background:var(--pp-surface)}` also style the preview.
    function scopeCss(css) {
        if (!css || !css.trim()) return '';
        return css.replace(/([^{}]+)\{/g, function (m, sel) {
            return sel.split(',').map(function (s) {
                s = s.trim();
                if (!s) return s;
                s = s.replace(/\.pp-topbar\.has-grad/g, '.pv-topbar.grad')
                     .replace(/\.pp-topbar/g, '.pv-topbar')
                     .replace(/\.pp-sidebar/g, '.pv-sidebar');
                if (s === ':root') return '#spPreview';
                if (s.indexOf(':root') === 0) return '#spPreview' + s.slice(5); // :root.is-dark -> #spPreview.is-dark
                return '#spPreview ' + s;
            }).join(', ') + ' {';
        });
    }
    function applyPreview() {
        if (!S.current) return;
        // Derived tokens change with every edit — drop the compiled cache so
        // the Palette tab re-resolves from the server next time it paints.
        if (typeof palState !== 'undefined') { palState.cache = null; if (S.tab === 'palette') renderPalette(); }
        styleEl().textContent = compile(S.current, '#spPreview') + '\n' + scopeCss(S.current.css || '');
        var pv = $('spPreview');
        // Off-canvas sidebar toggle for the tablet/mobile previews. Bound ONCE
        // (delegated) so it survives every innerHTML rebuild without leaking
        // listeners: the topbar menu button opens/closes the drawer, and tapping
        // the darkened main area closes it — mirroring the generated app.
        if (!pv._navBound) {
            pv._navBound = true;
            pv.addEventListener('click', function (e) {
                var sh = pv.querySelector('.pv-shell'); if (!sh || !e.target.closest) return;
                if (e.target.closest('.pv-tb-ico')) { sh.classList.toggle('pv-nav-open'); return; }
                if ((S.device === 'mobile' || S.device === 'tablet') && !e.target.closest('.pv-sidebar')) sh.classList.remove('pv-nav-open');
            });
        }
        var real = S.src === 'real';
        var main = real ? (realPageHtml() || '<div style="padding:40px;text-align:center;color:var(--pp-muted)">This table/page hasn’t been designed yet. Build it in the <strong>Designer</strong>, then preview it here.</div>') : SAMPLE_MAIN;
        pv.innerHTML = shellHtml(main);
        pv.setAttribute('data-src', S.src);
        pv.className = 'sp-pvframe dev-' + S.device + ' density-' + (S.current.density || 'normal') + (real ? ' ppd-canvas ppd-device-' + S.device : '') + (S.mode === 'dark' ? ' is-dark' : '');
        applyGridClasses();
        $('spPvMode').textContent = S.mode === 'dark' ? 'Dark mode' : 'Light mode';
    }
    // the "Real page" source dropdown: Templates · Common pages · Tables (grouped)
    function populateSources() {
        var tsel = $('spTable'), html = '';
        var tpls = Object.keys(boot.designerTemplates || {});
        var commons = Object.keys(boot.designerCommon || {});
        var tables = Object.keys(boot.designerPages || {});
        if (tpls.length) html += '<optgroup label="Templates">' + tpls.map(function (t) { return '<option value="tpl:' + esc(t) + '">' + esc(TPL_LABELS[t] || cap(t)) + ' template</option>'; }).join('') + '</optgroup>';
        if (commons.length) html += '<optgroup label="Common pages">' + commons.map(function (c) { return '<option value="common:' + esc(c) + '">' + esc(COMMON_LABELS[c] || cap(c)) + '</option>'; }).join('') + '</optgroup>';
        if (tables.length) html += '<optgroup label="Tables">' + tables.map(function (t) { return '<option value="table:' + esc(t) + '">' + esc(t) + '</option>'; }).join('') + '</optgroup>';
        tsel.innerHTML = html || '<option value="">— nothing designed yet —</option>';
        if (!S.srcKind) {
            if (tables.length) { S.srcKind = 'table'; S.table = tables[0]; }
            else if (tpls.length) { S.srcKind = 'tpl'; S.srcId = tpls[0]; }
            else if (commons.length) { S.srcKind = 'common'; S.srcId = commons[0]; }
        }
        tsel.value = S.srcKind === 'table' ? 'table:' + S.table : (S.srcKind ? S.srcKind + ':' + S.srcId : '');
        tsel.disabled = !html;
        populatePages();
    }
    function populatePages() {
        var psel = $('spPage');
        if (S.srcKind !== 'table') { psel.style.display = 'none'; return; }
        psel.style.display = '';
        var node = boot.designerPages && boot.designerPages[S.table];
        var types = node ? Object.keys(node) : [];
        psel.innerHTML = types.length ? types.map(function (t) { return '<option value="' + esc(t) + '">' + esc(TPL_LABELS[t] || cap(t)) + '</option>'; }).join('') : '<option value="">—</option>';
        if (types.length && types.indexOf(S.pageType) < 0) S.pageType = types[0];
        psel.value = S.pageType;
    }

    /* ---- theme list ---- */
    function index() { S.byId = {}; S.themes.forEach(function (t) { S.byId[t.id] = t; }); }
    function renderList() {
        var presets = S.themes.filter(function (t) { return !t.custom; });
        var custom = S.themes.filter(function (t) { return t.custom; });
        function row(t) {
            var active = S.current && (S.current.id === t.id || (S.current.isNew && t._draftFor === t.id)) ? ' active' : '';
            var sw = t.tokens || {};
            var c = {
                bg: sw.bg || '#f6f9fd', surface: sw.surface || '#ffffff',
                primary: sw.primary || '#2563eb', text: sw.text || '#0f172a',
                muted: sw.muted || sw.text || '#64748b', border: sw.border || 'rgba(0,0,0,.10)'
            };
            // Mini "app window" preview so themes read distinctly (canvas + surface
            // card + primary header bar + text lines) instead of one identical chip.
            var swatch = '<span class="sp-swatch" style="background:' + esc(c.bg) + ';border-color:' + esc(c.border) + '"' +
                (t.surface === 'glass' ? ' data-glass="1"' : '') + '>' +
                '<span class="sp-sw-win" style="background:' + esc(c.surface) + ';border-color:' + esc(c.border) + '">' +
                '<span class="sp-sw-top" style="background:' + esc(c.primary) + '"></span>' +
                '<span class="sp-sw-ln" style="background:' + esc(c.text) + '"></span>' +
                '<span class="sp-sw-ln sp-sw-ln--sm" style="background:' + esc(c.muted) + '"></span>' +
                '</span></span>';
            return '<div class="sp-theme' + active + '" data-theme="' + esc(t.id) + '">' +
                swatch +
                // One line: the group heading already says built-in vs custom, so
                // repeating it per row only made the list twice as tall.
                '<span class="sp-theme-meta"><strong>' + esc(t.label) + '</strong></span>' +
                (t.id === S.def ? '<span class="badge-def">default</span>' : '') +
                (t.custom ? '' : '<i class="bi bi-lock-fill sp-theme-lock" title="Built-in — duplicate it to customise"></i>') +
                '</div>';
        }
        var html = '<div class="sp-group-label">Built-in</div>' + presets.map(row).join('');
        html += '<div class="sp-group-label">Custom</div>' + (custom.length ? custom.map(row).join('') : '<div class="sp-empty-note">None yet — duplicate a built-in to start.</div>');
        $('spThemeList').innerHTML = html;
        [].forEach.call(document.querySelectorAll('.sp-theme[data-theme]'), function (el) {
            el.addEventListener('click', function () { selectTheme(el.getAttribute('data-theme'), true); });
        });
    }

    function clone(t) { return JSON.parse(JSON.stringify(t)); }
    function selectTheme(id, persistDefault) {
        var t = S.byId[id]; if (!t) return;
        S.current = clone(t); S.current.isNew = false; S.dirty = false;
        // Overlay the project-level layout so switching themes keeps it
        if (S.projectShell) {
            S.current.shell = S.current.shell || {};
            deepMergeInto(S.current.shell, S.projectShell);
        }
        renderAll();
        // Choosing a theme makes it the project's active theme — the Build
        // compiles the active theme into the generated app.
        if (persistDefault && id !== S.def) {
            post('/save', { default: id }).then(function (res) {
                refresh(res); renderList();
                flash('“' + (t.label || id) + '” is now the active theme');
            });
        }
    }

    /* ---- editor ---- */
    // Built-in presets are editable too: saving one creates the project's own
    // copy (the server shadows the preset id), so Layout/token changes stick.
    // Built-in presets ship with the product — editing one in place would let a
    // developer break a theme every project starts from. They are read-only;
    // Duplicate makes an editable custom copy.
    function isReadonly() { return isBuiltin(); }

    /**
     * Enforce the lock across the whole editor body. renderTokens() honours
     * isReadonly() for the colour fields, but the Layout / Grid / Menu / CSS
     * tabs build their own controls, so disable them here after every render
     * rather than teaching each renderer about locking.
     */
    function applyLock() {
        var locked = isReadonly();
        var body = document.querySelector('.sp-ed-body');
        if (body) {
            [].forEach.call(body.querySelectorAll('input, select, textarea, button'), function (el) {
                if (el.closest('#spMainTabs') || el.closest('#spModeTabs') || el.closest('#spPalette')) return;
                el.disabled = locked;
            });
            body.classList.toggle('is-locked', locked);
            // The reason lives on the fields themselves rather than a banner —
            // the banner cost permanent vertical space to say something you only
            // need when you actually try to change a value.
            //
            // ⚠️ The tip goes on the ROW WRAPPER, not the input: disabled form
            // controls do not fire mouse events, so a tip bound to the input
            // would never appear.
            var TIP = 'Built-in theme — locked. Press “Duplicate to edit” to make your own editable copy.';
            [].forEach.call(body.querySelectorAll('.sp-tok, .sp-ctl'), function (row) {
                if (locked) row.setAttribute('data-sp-tip', TIP);
                else row.removeAttribute('data-sp-tip');
            });
        }
        var name = $('spName');
        if (name) name.readOnly = locked;
        var save = $('spSave');
        if (save) {
            save.innerHTML = locked
                ? '<i class="bi bi-files"></i><span>Duplicate</span>'
                : '<i class="bi bi-check-lg"></i><span>Save</span>';
            save.setAttribute('data-sp-tip', locked
                ? 'Make an editable copy of this built-in theme'
                : 'Save this theme');
            save.disabled = false;
        }
    }
    function isBuiltin() { var t = S.current; return t && !t.custom && !t.isNew; }
    function renderMeta() {
        var t = S.current; if (!t) return;
        $('spName').value = t.label || '';
        $('spName').readOnly = isReadonly();
        $('spSub').textContent = isBuiltin()
            ? 'Built-in · locked — duplicate it to edit'
            : (t.isNew ? 'New theme — name it and Save' : 'Custom theme · id: ' + t.id);
        $('spSave').disabled = false;
        $('spDelete').disabled = !(t.custom && !t.isNew);
        $('spDefault').disabled = t.isNew;
        $('spExport').disabled = false;
    }
    function renderTokens() {
        var t = S.current; if (!t) return;
        var readonly = isReadonly();
        var html = '';
        GROUPS.forEach(function (g) {
            html += '<div class="sp-tok-group">' + esc(g.label) + '</div>';
            g.keys.forEach(function (k) {
                var isColor = COLOR_KEYS.indexOf(k) > -1;
                var lightVal = (t.tokens || {})[k] || '';
                var val = S.mode === 'dark' ? ((t.dark || {})[k] || '') : lightVal;
                var ph = S.mode === 'dark' ? ('inherits ' + (lightVal || '—')) : '';
                var dis = readonly ? ' disabled' : '';
                var ctrl = '<div class="sp-cin">';
                if (isColor) { ctrl += '<input type="color" data-k="' + k + '" data-c="1" value="' + esc(/^#[0-9a-f]{6}$/i.test(val) ? val : (/^#[0-9a-f]{6}$/i.test(lightVal) ? lightVal : '#ffffff')) + '"' + dis + '>'; }
                ctrl += '<input type="text" data-k="' + k + '" value="' + esc(val) + '" placeholder="' + esc(ph) + '"' + dis + '></div>';
                html += '<div class="sp-tok"><label title="' + esc(VARMAP[k]) + '">' + esc(LABELS[k] || k) + '</label>' + ctrl + '</div>';
            });
        });
        $('spTokens').innerHTML = html;
        bindTokens();
    }
    function setToken(k, v) {
        var bag = S.mode === 'dark' ? (S.current.dark || (S.current.dark = {})) : (S.current.tokens || (S.current.tokens = {}));
        if (v === '') { delete bag[k]; } else { bag[k] = v; }
        S.dirty = true;
        applyPreview();
    }
    function bindTokens() {
        [].forEach.call($('spTokens').querySelectorAll('input[type=text][data-k]'), function (inp) {
            inp.addEventListener('input', function () {
                setToken(inp.getAttribute('data-k'), inp.value.trim());
                var sw = $('spTokens').querySelector('input[data-c][data-k="' + inp.getAttribute('data-k') + '"]');
                if (sw && /^#[0-9a-f]{6}$/i.test(inp.value.trim())) sw.value = inp.value.trim();
            });
        });
        [].forEach.call($('spTokens').querySelectorAll('input[data-c][data-k]'), function (sw) {
            sw.addEventListener('input', function () {
                var k = sw.getAttribute('data-k');
                setToken(k, sw.value);
                var tx = $('spTokens').querySelector('input[type=text][data-k="' + k + '"]');
                if (tx) tx.value = sw.value;
            });
        });
    }

    /* ---- generic editor controls (data-sp = dot-path into S.current) ---- */
    function hex(v) { return /^#[0-9a-f]{6}$/i.test(v) ? v : '#ffffff'; }
    function ctlWrap(label, control) { return '<div class="sp-ctl"><label>' + esc(label) + '</label>' + control + '</div>'; }
    function ctlSel(label, path, val, opts) { return ctlWrap(label, '<select data-sp="' + path + '">' + opts.map(function (o) { return '<option' + (String(o) === String(val) ? ' selected' : '') + '>' + esc(o) + '</option>'; }).join('') + '</select>'); }
    function ctlText(label, path, val, ph) { return ctlWrap(label, '<input type="text" data-sp="' + path + '" value="' + esc(val || '') + '" placeholder="' + esc(ph || '') + '">'); }
    function ctlColor(label, path, val) { return ctlWrap(label, '<span class="sp-cin"><input type="color" data-sp="' + path + '" data-c="1" value="' + esc(hex(val)) + '"><input type="text" data-sp="' + path + '" value="' + esc(val || '') + '" style="flex:1"></span>'); }
    function ctlCheck(label, path, on) { return '<label class="sp-checkrow"><input type="checkbox" data-sp="' + path + '"' + (on ? ' checked' : '') + '> ' + esc(label) + '</label>'; }
    // ── Floating hint tooltip ────────────────────────────────────────────────
    // Rendered into <body> and positioned with position:fixed, because the
    // settings panel is overflow:auto — any bubble drawn inside it gets clipped
    // once the anchor nears the panel edge. Flips above/below by available room
    // and clamps horizontally, so it is always fully visible.
    var spTip = (function () {
        var el = null, anchorEl = null;
        function node() {
            if (!el) {
                el = document.createElement('div');
                el.className = 'sp-tip';
                el.setAttribute('role', 'tooltip');
                document.body.appendChild(el);
            }
            return el;
        }
        function place() {
            if (!anchorEl || !el) return;
            var a = anchorEl.getBoundingClientRect();
            var w = el.offsetWidth, h = el.offsetHeight, m = 8;
            var below = a.bottom + m + h <= window.innerHeight - m;
            var top = below ? a.bottom + m : a.top - m - h;
            var left = a.left + a.width / 2 - w / 2;
            left = Math.max(m, Math.min(left, window.innerWidth - w - m));
            el.style.top = Math.round(top) + 'px';
            el.style.left = Math.round(left) + 'px';
            el.classList.toggle('is-above', !below);
        }
        function show(target, text) {
            if (!text) return;
            anchorEl = target;
            var n = node();
            n.textContent = text;
            n.classList.add('is-on');
            place();
        }
        function hide() { anchorEl = null; if (el) el.classList.remove('is-on'); }
        window.addEventListener('scroll', function () { if (anchorEl) place(); }, true);
        window.addEventListener('resize', hide);
        return { show: show, hide: hide };
    })();

    // Delegated once for the whole SPA: anything with data-sp-tip gets a bubble.
    document.addEventListener('mouseover', function (e) {
        var t = e.target.closest && e.target.closest('[data-sp-tip]');
        if (t) spTip.show(t, t.getAttribute('data-sp-tip'));
    });
    document.addEventListener('mouseout', function (e) {
        var t = e.target.closest && e.target.closest('[data-sp-tip]');
        if (t) spTip.hide();
    });
    document.addEventListener('focusin', function (e) {
        var t = e.target.closest && e.target.closest('[data-sp-tip]');
        if (t) spTip.show(t, t.getAttribute('data-sp-tip'));
    });
    document.addEventListener('focusout', function () { spTip.hide(); });
    document.addEventListener('keydown', function (e) { if (e.key === 'Escape') spTip.hide(); });

    function grp(t, hint) {
        return '<div class="sp-ctl-group">' + esc(t) +
            (hint ? '<span class="sp-hintchip" tabindex="0" role="note" data-sp-tip="' + esc(hint) + '"' +
                    ' aria-label="' + esc(hint) + '">?</span>' : '') +
            '</div>';
    }
    function bindEditor(host) {
        [].forEach.call(host.querySelectorAll('[data-sp]'), function (el) {
            var evt = (el.type === 'checkbox' || el.tagName === 'SELECT' || el.type === 'color') ? 'change' : 'input';
            el.addEventListener(evt, function () {
                var path = el.getAttribute('data-sp');
                var val = el.type === 'checkbox' ? el.checked : el.value;
                setPath(S.current, path, val);
                if (path.indexOf('shell.') === 0) {
                    S.projectShell = S.projectShell || {};
                    setPath(S.projectShell, path.slice(6), val);
                    saveShellSoon();
                }
                S.dirty = true;
                if (el.getAttribute('data-c') && /^#[0-9a-f]{6}$/i.test(el.value)) { var tx = host.querySelector('input[type=text][data-sp="' + path + '"]'); if (tx) tx.value = el.value; }
                if (el.type === 'text') { var sw = host.querySelector('input[data-c][data-sp="' + path + '"]'); if (sw && /^#[0-9a-f]{6}$/i.test(el.value)) sw.value = el.value; }
                applyPreview();
                if (path === 'css') { var c = $('spCompiled'); if (c) c.textContent = compile(S.current, ':root'); }
                // Some settings decide which OTHER controls are relevant (e.g.
                // Fixed gates the frosted/shrink options, Compact reveals the
                // rail widths). Re-render the panel so it stays truthful, and
                // restore focus so the user is not thrown out of the control.
                if (RERENDER_LAYOUT[path] && S.tab === 'layout') {
                    renderLayout();
                    var again = $('spLayout').querySelector('[data-sp="' + path + '"]');
                    if (again) again.focus();
                }
            });
        });
    }
    // Paths whose value changes which other controls are shown.
    var RERENDER_LAYOUT = {
        'shell.topbar.fixed': true,
        'shell.sidebar.compact': true,
        'shell.sidebar.autoHide': true
    };
    function pathBool(p, dflt) { var v = getPath(S.current, p); return v == null ? dflt : v !== false; }

    // Tiny wireframes of each layout, drawn inline. A miniature of the real
    // frame reads far quicker than a generic glyph, it tints with the tile via
    // currentColor, and it carries no icon-font dependency.
    var NAVBAR_SVG = (function () {
        function svg(body) {
            return '<svg viewBox="0 0 32 24" width="30" height="22" aria-hidden="true" focusable="false">' +
                '<rect x="0.6" y="0.6" width="30.8" height="22.8" rx="3" fill="none" stroke="currentColor" stroke-opacity=".45"/>' +
                body + '</svg>';
        }
        var bar = 'fill="currentColor"';
        return {
            //         top bar                                    left sidebar
            classic:    svg('<rect x="1" y="1" width="30" height="5" rx="2" ' + bar + '/>' +
                            '<rect x="1" y="7" width="9"  height="16" rx="2" ' + bar + ' fill-opacity=".38"/>'),
            horizontal: svg('<rect x="1" y="1" width="30" height="7" rx="2" ' + bar + '/>' +
                            '<rect x="4" y="3.5" width="6" height="2" rx="1" fill="var(--sp-surface)" fill-opacity=".85"/>' +
                            '<rect x="12" y="3.5" width="6" height="2" rx="1" fill="var(--sp-surface)" fill-opacity=".85"/>' +
                            '<rect x="20" y="3.5" width="6" height="2" rx="1" fill="var(--sp-surface)" fill-opacity=".85"/>'),
            sidebar:    svg('<rect x="1" y="1" width="10" height="22" rx="2" ' + bar + '/>' +
                            '<rect x="12" y="1" width="19" height="4" rx="2" ' + bar + ' fill-opacity=".38"/>'),
            rail:       svg('<rect x="1" y="1" width="5" height="22" rx="2" ' + bar + '/>' +
                            '<rect x="7" y="1" width="24" height="5" rx="2" ' + bar + ' fill-opacity=".38"/>'),
            boxed:      svg('<rect x="4" y="3" width="24" height="5" rx="2.5" ' + bar + '/>' +
                            '<rect x="4" y="10" width="24" height="11" rx="2.5" ' + bar + ' fill-opacity=".22"/>'),
            hero:       svg('<rect x="1" y="1" width="30" height="11" rx="2" ' + bar + '/>' +
                            '<rect x="4" y="4" width="12" height="2.5" rx="1.2" fill="var(--sp-surface)" fill-opacity=".85"/>' +
                            '<rect x="4" y="8" width="7" height="1.6" rx=".8" fill="var(--sp-surface)" fill-opacity=".6"/>'),
            _fallback:  svg('<rect x="1" y="1" width="30" height="5" rx="2" ' + bar + '/>')
        };
    })();
    function deepMergeInto(t, s) { Object.keys(s || {}).forEach(function (k) { if (s[k] && typeof s[k] === 'object' && !Array.isArray(s[k])) { t[k] = t[k] || {}; deepMergeInto(t[k], s[k]); } else { t[k] = s[k]; } }); return t; }
    function applyNavbar(id) {
        var n = (boot.navbarTypes || []).filter(function (x) { return x.id === id; })[0]; if (!n) return;
        S.current.shell = S.current.shell || {};
        S.current.shell.navbar = id;
        deepMergeInto(S.current.shell, n.defaults || {});
        S.current.shell.menu = S.current.shell.menu || {};
        S.current.shell.menu.placement = n.menuPlacement || 'sidebar';
        S.projectShell = deepMergeInto(S.projectShell || {}, JSON.parse(JSON.stringify(S.current.shell)));
        saveShellSoon();
        S.dirty = true; renderLayout(); applyPreview();
    }
    function renderLayout() {
        var packs = boot.shellPalettes || ['navy', 'dark', 'slate', 'light', 'white', 'primary'];
        var g = getPath.bind(null, S.current);
        var navbars = boot.navbarTypes || [];
        var curNav = g('shell.navbar') || 'classic';
        // Compact tiles: icon + name only. The description lives in the hover
        // tooltip — as full text it was forcing the whole panel wide.
        var h = grp('Navbar type', 'The overall page frame. Hover a tile to see what it does; options below adapt to your choice.') +
            '<div class="sp-navbars" id="spNavbars">' + navbars.map(function (n) {
            var on = n.id === curNav;
            return '<button type="button" class="sp-navbar-opt' + (on ? ' active' : '') + '"' +
                ' data-nav="' + esc(n.id) + '" data-desc="' + esc(n.description) + '"' +
                ' aria-pressed="' + (on ? 'true' : 'false') + '">' +
                '<span class="nv-tick"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true">' +
                    '<path d="M3 8.5l3.2 3.2L13 5" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span>' +
                '<span class="nv-ico">' + (NAVBAR_SVG[n.id] || NAVBAR_SVG._fallback) + '</span>' +
                '<span class="nv-meta"><strong>' + esc(n.label) + '</strong></span></button>';
        }).join('') + '</div>' +
            // Descriptions live here rather than in a hover bubble: the settings
            // panel scrolls (overflow:auto), so a tooltip wide enough to hold this
            // text gets clipped at the panel edge. A strip is always fully legible,
            // and it still previews on hover before you commit to a choice.
            '<div class="sp-navbar-desc" id="spNavbarDesc"></div>';
        h += grp('Global', 'Density sets the spacing rhythm. Surface switches the whole app between flat and frosted “glass” (translucent, blurred, with a refraction edge).') + ctlSel('Density', 'density', S.current.density || 'normal', ['compact', 'normal', 'comfortable']) + ctlSel('Surface', 'surface', S.current.surface || 'flat', ['flat', 'glass']);
        h += grp('Topbar', 'The bar across the top. Fixed pins it while the page scrolls; Search adds a quick-find over your menu.') +
            ctlCheck('Show topbar', 'shell.topbar.enabled', pathBool('shell.topbar.enabled', true)) +
            ctlSel('Theme', 'shell.topbar.theme', g('shell.topbar.theme') || 'navy', packs) +
            ctlText('Height', 'shell.topbar.height', g('shell.topbar.height') || '56px', '56px') +
            ctlCheck('Fixed', 'shell.topbar.fixed', pathBool('shell.topbar.fixed', true)) +
            ctlCheck('Gradient', 'shell.topbar.gradient', !!g('shell.topbar.gradient')) +
            ctlSel('Edge', 'shell.topbar.elevation', g('shell.topbar.elevation') || 'shadow', ['shadow', 'border', 'none']) +
            ctlCheck('Show search', 'shell.topbar.search', pathBool('shell.topbar.search', true)) +
            ctlCheck('Show user menu', 'shell.topbar.user', pathBool('shell.topbar.user', true)) +
            // Both need a pinned bar to mean anything, so they only appear when Fixed is on.
            (pathBool('shell.topbar.fixed', true)
                ? ctlCheck('Frosted (blur)', 'shell.topbar.blur', !!g('shell.topbar.blur')) +
                  ctlCheck('Shrink on scroll', 'shell.topbar.shrink', !!g('shell.topbar.shrink'))
                : '<div class="sp-hint">Pin the bar with <strong>Fixed</strong> to enable the frosted and shrink-on-scroll effects.</div>');
        h += grp('Sidebar', 'The vertical navigation panel. Compact shows icons only and expands on hover; Fixed keeps it in place while the page scrolls.') +
            ctlCheck('Show sidebar', 'shell.sidebar.enabled', pathBool('shell.sidebar.enabled', true)) +
            ctlSel('Position', 'shell.sidebar.position', g('shell.sidebar.position') || 'left', ['left', 'right']) +
            ctlSel('Theme', 'shell.sidebar.theme', g('shell.sidebar.theme') || 'navy', packs) +
            ctlText('Width', 'shell.sidebar.width', g('shell.sidebar.width') || '250px', '250px') +
            ctlCheck('Compact (icons only)', 'shell.sidebar.compact', !!g('shell.sidebar.compact')) +
            ctlCheck('Fixed', 'shell.sidebar.fixed', pathBool('shell.sidebar.fixed', true)) +
            ctlCheck('Auto hide (shrink to icons)', 'shell.sidebar.autoHide', !!g('shell.sidebar.autoHide')) +
            (g('shell.sidebar.autoHide')
                ? ctlText('Auto hide below', 'shell.sidebar.autoHideBelow', g('shell.sidebar.autoHideBelow') || '1280px', '1280px') +
                  '<div class="sp-hint">Below this window width the sidebar shrinks to icons so wide pages get room. Hover it to peek the labels. It never overrides a collapse you chose yourself.</div>'
                : '');
        // ── Options that only make sense for the selected navbar type. Showing
        //    every knob for every layout is noise, so each block is contextual. ──
        if (curNav === 'rail' || g('shell.sidebar.compact')) {
            h += grp('Icon rail', 'Widths for the collapsed rail and its hover-expanded state.') +
                ctlText('Collapsed width', 'shell.sidebar.railWidth', g('shell.sidebar.railWidth') || '72px', '72px') +
                ctlText('Expanded width (on hover)', 'shell.sidebar.railExpanded', g('shell.sidebar.railExpanded') || '230px', '230px') +
                '<div class="sp-hint">The rail shows icons only and widens on hover.</div>';
        }
        if (curNav === 'boxed') {
            h += grp('Boxed layout', 'Caps the app width and centres it — useful on very wide monitors.') +
                ctlText('Max width', 'shell.boxed.maxWidth', g('shell.boxed.maxWidth') || '1240px', '1240px') +
                '<div class="sp-hint">The app is centred and capped at this width.</div>';
        }
        if (curNav === 'hero') {
            h += grp('Hero bar', 'The tall gradient header. The gradient runs from the topbar colour to the theme primary.') +
                ctlText('Height', 'shell.hero.height', g('shell.hero.height') || '84px', '84px') +
                ctlText('Gradient angle', 'shell.hero.angle', g('shell.hero.angle') || '135deg', '135deg') +
                '<div class="sp-hint">The gradient runs from the topbar colour to the theme primary.</div>';
        }
        h += grp('Logo', 'Brand mark shown in the topbar or sidebar. An image URL overrides the text.') +
            ctlText('Text', 'shell.logo.text', g('shell.logo.text') || 'APP', 'APP') +
            ctlText('Brand logo (image URL)', 'shell.logo.image', g('shell.logo.image') || '', 'https://… or /assets/logo.png') +
            ctlSel('Theme', 'shell.logo.theme', g('shell.logo.theme') || 'navy', packs);
        h += grp('Favicon', 'The little icon shown in the browser tab &amp; bookmarks. A square SVG/PNG works best. Falls back to the logo image when blank.') +
            ctlText('Favicon (image URL)', 'shell.favicon', g('shell.favicon') || '', '/custom/favicon.svg  or  https://…');
        h += grp('Menu', 'How navigation items look, and whether they sit in the sidebar or the topbar.') +
            ctlSel('Style', 'shell.menu.style', g('shell.menu.style') || 'rounded', ['rounded', 'flat', 'pill']) +
            ctlSel('Placement', 'shell.menu.placement', g('shell.menu.placement') || 'sidebar', ['sidebar', 'topbar']);
        h += grp('Footer', 'The strip at the bottom of every page. Supports PHP snippets in braces.') +
            ctlCheck('Show footer', 'shell.footer.enabled', pathBool('shell.footer.enabled', true)) +
            ctlText('Text', 'shell.footer.text', g('shell.footer.text') || '', "© Your Company {date('Y')}") +
            ctlSel('Placement', 'shell.footer.align', g('shell.footer.align') || 'left', ['left', 'center', 'right']) +
            '<div class="sp-hint">PHP snippets run in the built app: wrap an expression in braces — e.g. <code>© Your Company {date(\'Y\')}</code> shows the current year.</div>';
        $('spLayout').innerHTML = h; bindEditor($('spLayout'));
        var descBox = $('spNavbarDesc');
        var showDesc = function (el) {
            if (!descBox) return;
            var src = el || $('spLayout').querySelector('.sp-navbar-opt.active');
            descBox.textContent = src ? (src.getAttribute('data-desc') || '') : '';
        };
        showDesc(null);
        [].forEach.call($('spLayout').querySelectorAll('[data-nav]'), function (b) {
            b.addEventListener('click', function () { applyNavbar(b.getAttribute('data-nav')); });
            // preview on hover/focus, snap back to the selected one on leave
            b.addEventListener('mouseenter', function () { showDesc(b); });
            b.addEventListener('focus', function () { showDesc(b); });
            b.addEventListener('mouseleave', function () { showDesc(null); });
            b.addEventListener('blur', function () { showDesc(null); });
        });
    }
    function renderGrid() {
        var g = S.current.grid || {};
        var h = grp('Grid appearance') +
            ctlCheck('Panel-like grid', 'grid.panel', !!g.panel) +
            ctlCheck('Vertical borders', 'grid.verticalBorders', g.verticalBorders !== false) +
            ctlCheck('Zebra striping', 'grid.zebra', !!g.zebra) +
            ctlCheck('Condensed', 'grid.condensed', !!g.condensed) +
            ctlCheck('Rollover row highlight', 'grid.rollover', g.rollover !== false) +
            grp('Grid colours') +
            ctlColor('Panel color', 'grid.panelColor', g.panelColor || '#ffffff') +
            ctlColor('Grid color', 'grid.gridColor', g.gridColor || '#ffffff');
        $('spGrid').innerHTML = h; bindEditor($('spGrid'));
    }
    function renderCss() {
        var h = grp('Custom CSS') +
            '<textarea class="sp-cssarea" data-sp="css" placeholder="/* Global CSS applied to the generated app */">' + esc(S.current.css || '') + '</textarea>' +
            '<div class="sp-code-head">Compiled theme CSS</div><pre class="sp-code" id="spCompiled"></pre>';
        $('spCss').innerHTML = h; bindEditor($('spCss'));
        $('spCompiled').textContent = compile(S.current, ':root');
    }

    /* ============================================================
       Menu editor — modular tree with drag/drop + a rich Item Editor.
       Items are addressed by a stable `id`; the tree is 2 levels deep.
       ============================================================ */
    var menuSaveT = null;
    function saveMenuSoon() { clearTimeout(menuSaveT); menuSaveT = setTimeout(function () { post('/save', { menu: S.menu }); }, 600); recordHistory(false); }
    function menuUid() { return 'mi_' + Math.random().toString(36).slice(2, 10); }
    function newItem(o) { return Object.assign({ id: menuUid(), type: 'hyperlink', label: 'New item', icon: '', showIcon: 'always', link: { kind: 'page', page: '', subpage: 'list', url: '', params: '', target: 'self' }, css: '', roles: [], children: [] }, o || {}); }
    function newDivider(o) { return Object.assign({ id: menuUid(), type: 'divider', dividerStyle: 'line', label: '', roles: [], children: [] }, o || {}); }
    // The five divider presets offered in the editor.
    var DIVIDER_STYLES = [
        { id: 'line', label: 'Line', hint: 'Solid rule' },
        { id: 'dashed', label: 'Dashed', hint: 'Dashed rule' },
        { id: 'dotted', label: 'Dotted', hint: 'Dotted rule' },
        { id: 'gradient', label: 'Fade', hint: 'Gradient that fades at the edges' },
        { id: 'label', label: 'Heading', hint: 'Section heading text' }
    ];
    // locate an item by id → { arr, index, item, parent }
    function findItem(id, items, parent) {
        items = items || S.menu.items;
        for (var i = 0; i < items.length; i++) {
            if (items[i].id === id) return { arr: items, index: i, item: items[i], parent: parent };
            if (items[i].children && items[i].children.length) { var r = findItem(id, items[i].children, items[i]); if (r) return r; }
        }
        return null;
    }
    var menuCollapsed = {};   // UI-only: submenu id → collapsed
    var menuEd = null;        // the Menu Editor modal element (built lazily)
    var menuSelId = null;     // id of the item shown in the detail pane
    var menuPreviewRole = '__all';   // "Preview as" role filter in the modal preview
    var menuFilterText = '';  // tree search box
    var ROLE_SUGGESTIONS = ['admin', 'manager', 'staff', 'editor', 'user'];
    var menuUndo = [], menuRedo = [], menuHistTimer = null, menuLastSnap = null;  // undo/redo
    // Menu spacing (applied to the generated sidebar + the live preview).
    var DEFAULT_SPACING = { height: 38, padX: 12, gap: 2, divGap: 8 };
    function clampNum(v, lo, hi, def) { v = parseInt(v, 10); if (isNaN(v)) return def; return Math.max(lo, Math.min(hi, v)); }
    function menuSpacing() {
        var s = (S.menu && S.menu.spacing) || {};
        return {
            height: clampNum(s.height, 26, 64, DEFAULT_SPACING.height),
            padX: clampNum(s.padX, 4, 28, DEFAULT_SPACING.padX),
            gap: clampNum(s.gap, 0, 16, DEFAULT_SPACING.gap),
            divGap: clampNum(s.divGap, 2, 28, DEFAULT_SPACING.divGap)
        };
    }
    function menuRow(it, depth, collapsed) {
        var selD = (it.id === menuSelId) ? ' is-selected' : '';
        if (it.type === 'divider') {
            var ds = it.dividerStyle || 'line';
            var rolesD = (it.roles && it.roles.length) ? '<span class="mi-roles" data-tip="Visible to: ' + esc(it.roles.join(', ')) + '"><i class="bi bi-shield-lock-fill"></i>' + it.roles.length + '</span>' : '';
            var prev = ds === 'label'
                ? '<span class="mi-div-heading">' + esc(it.label || 'Section') + '</span>'
                : '<span class="sp-div-rule sp-div-' + ds + '"></span>';
            return '<div class="sp-mi sp-mi-divider' + (depth ? ' child' : '') + selD + '" draggable="true" data-id="' + esc(it.id) + '">' +
                '<span class="drag"><i class="bi bi-grip-vertical"></i></span><span class="mi-toggle-sp"></span>' +
                '<span class="mi-div-tag" data-tip="Divider">' + (ds === 'label' ? '<i class="bi bi-type"></i>' : '<i class="bi bi-dash-lg"></i>') + '</span>' +
                '<span class="mi-div-preview">' + prev + '</span>' + rolesD +
                '<button data-act="edit" data-tip="Edit"><i class="bi bi-pencil"></i></button>' +
                '<button class="del" data-act="del" data-tip="Remove"><i class="bi bi-trash"></i></button></div>';
        }
        var linkTxt = it.type === 'submenu' ? 'submenu' : (it.link && it.link.kind === 'external' ? (it.link.url || 'external') : (it.link && it.link.page !== '' ? (it.link.page + '/' + (it.link.subpage || 'list')) : (it.link && it.link.page === '' && it.link.kind === 'page' ? 'home' : '—')));
        var hasKids = it.children && it.children.length;
        var toggle = (depth === 0 && it.type === 'submenu')
            ? '<button class="mi-toggle" data-act="toggle" data-tip="' + (collapsed ? 'Expand' : 'Collapse') + '"><i class="bi bi-chevron-' + (collapsed ? 'right' : 'down') + '"></i></button>'
            : '<span class="mi-toggle-sp"></span>';
        var count = (depth === 0 && hasKids) ? '<span class="mi-count" data-tip="' + it.children.length + ' item' + (it.children.length > 1 ? 's' : '') + '">' + it.children.length + '</span>' : '';
        var roles = (it.roles && it.roles.length) ? '<span class="mi-roles" data-tip="Visible to: ' + esc(it.roles.join(', ')) + '"><i class="bi bi-shield-lock-fill"></i>' + it.roles.length + '</span>' : '';
        var sel = (it.id === menuSelId) ? ' is-selected' : '';
        return '<div class="sp-mi' + (depth ? ' child' : '') + (it.type === 'submenu' ? ' is-sub' : '') + sel + '" draggable="true" data-id="' + esc(it.id) + '">' +
            '<span class="drag"><i class="bi bi-grip-vertical"></i></span>' + toggle +
            '<span class="mi-ico">' + (it.icon ? iconHtml(it.icon) : '<i class="bi bi-' + (it.type === 'submenu' ? 'folder2' : 'link-45deg') + '" style="opacity:.4"></i>') + '</span>' +
            '<span class="mi-label">' + esc(it.label || 'Item') + '</span>' + count + roles +
            '<span class="mi-link">' + esc(linkTxt) + '</span>' +
            '<button data-act="edit" data-tip="Edit"><i class="bi bi-pencil"></i></button>' +
            '<button class="del" data-act="del" data-tip="Remove"><i class="bi bi-trash"></i></button></div>';
    }
    /* Tally of the current menu (top-level items, groups, links, dividers). */
    function menuCounts() {
        var items = S.menu.items || [];
        var groups = 0, links = 0, dividers = 0;
        (function tally(arr) { (arr || []).forEach(function (it) {
            if (it.type === 'submenu') { groups++; tally(it.children); }
            else if (it.type === 'divider') dividers++;
            else links++;
        }); })(items);
        return { top: items.length, groups: groups, links: links, dividers: dividers };
    }
    function countRestricted() {
        var n = 0;
        (function walk(arr) { (arr || []).forEach(function (it) { if (it.roles && it.roles.length) n++; walk(it.children); }); })(S.menu.items || []);
        return n;
    }

    /* ---- Menu tab panel: a launcher card that opens the full Menu Editor. ---- */
    function renderMenu() {
        var host = $('spMenu'); if (!host) return;
        var items = S.menu.items || (S.menu.items = []);
        var c = menuCounts(); var restricted = countRestricted();
        var preview = items.length
            ? '<div class="sp-menu-launch-list" id="spMenuLaunchList">' + items.slice(0, 7).map(function (it) {
                var ic = '<span class="mi-ico">' + (it.icon ? iconHtml(it.icon) : '<i class="bi bi-' + (it.type === 'submenu' ? 'folder2' : 'dot') + '"></i>') + '</span>';
                var badge = (it.children && it.children.length) ? '<span class="sp-ml-count">' + it.children.length + '</span>' : '';
                var lock = (it.roles && it.roles.length) ? '<i class="bi bi-shield-lock sp-ml-lock" title="' + esc(it.roles.join(', ')) + '"></i>' : '';
                return '<div class="sp-menu-launch-item">' + ic + '<span class="lbl">' + esc(it.label || 'Item') + '</span>' + lock + badge + '</div>';
            }).join('') + (items.length > 7 ? '<div class="sp-menu-launch-more">+' + (items.length - 7) + ' more…</div>' : '') + '</div>'
            : '<div class="sp-menu-launch-empty"><i class="bi bi-signpost-2"></i><span>No menu items yet.</span></div>';
        host.innerHTML =
            '<div class="sp-menu-launch">' +
                '<div class="sp-ml-head"><span class="sp-ml-ic"><i class="bi bi-list-nested"></i></span>' +
                    '<div class="sp-ml-meta"><strong>Navigation menu</strong><small>' + c.top + ' top-level · ' + c.groups + ' group' + (c.groups === 1 ? '' : 's') + ' · ' + c.links + ' link' + (c.links === 1 ? '' : 's') + (restricted ? ' · <span class="sp-ml-restricted"><i class="bi bi-shield-lock"></i>' + restricted + ' restricted</span>' : '') + '</small></div></div>' +
                preview +
                '<button type="button" class="sp-btn primary sp-ml-open" id="spMenuOpen"><i class="bi bi-pencil-square"></i> Open Menu Editor</button>' +
                '<div class="sp-menu-hint"><i class="bi bi-info-circle"></i> Build groups &amp; links, set icons, and control who sees each item by role.</div>' +
            '</div>';
        $('spMenuOpen').addEventListener('click', function () { openMenuEditor(); });
        var list = $('spMenuLaunchList'); if (list) list.addEventListener('click', function () { openMenuEditor(); });
    }
    function afterMenuChange() {
        if (menuEd && !menuEd.hasAttribute('hidden')) { renderMenuTree(); renderMenuDetail(); renderMenuMiniPreview(); populateAsRole(); renderMenuFoot(); }
        renderMenu();
        applyPreview(); saveMenuSoon();
        recordHistory(true);
    }

    /* ---- undo / redo (snapshots of the whole menu tree) ---- */
    function menuSnap() { return JSON.stringify(S.menu); }
    function historyInit() { menuLastSnap = menuSnap(); menuUndo = []; menuRedo = []; updateUndoRedo(); }
    function recordHistory(immediate) {
        clearTimeout(menuHistTimer);
        function commit() {
            var cur = menuSnap();
            if (menuLastSnap === null) { menuLastSnap = cur; return; }
            if (cur === menuLastSnap) return;
            menuUndo.push(menuLastSnap);
            if (menuUndo.length > 120) menuUndo.shift();
            menuLastSnap = cur; menuRedo = [];
            updateUndoRedo();
        }
        if (immediate) commit(); else menuHistTimer = setTimeout(commit, 550);
    }
    function applyHistory(snap) {
        S.menu = JSON.parse(snap); menuLastSnap = snap;
        if (menuSelId && !findItem(menuSelId)) menuSelId = (S.menu.items[0] ? S.menu.items[0].id : null);
        if (menuEd && !menuEd.hasAttribute('hidden')) { renderMenuTree(); renderMenuDetail(); renderMenuMiniPreview(); populateAsRole(); renderMenuFoot(); }
        renderMenu(); applyPreview(); saveMenuSoon(); updateUndoRedo();
    }
    function menuUndoDo() { if (!menuUndo.length) return; menuRedo.push(menuLastSnap); applyHistory(menuUndo.pop()); }
    function menuRedoDo() { if (!menuRedo.length) return; menuUndo.push(menuLastSnap); applyHistory(menuRedo.pop()); }
    function updateUndoRedo() {
        var u = menuEd && menuEd.querySelector('#spMenuUndo'), r = menuEd && menuEd.querySelector('#spMenuRedo');
        if (u) u.disabled = !menuUndo.length;
        if (r) r.disabled = !menuRedo.length;
    }

    /* ---- default menu built from the project's REAL list pages ---- */
    function buildDefaultMenu() {
        var pages = boot.designerPages || {};
        var tables = Object.keys(pages).filter(function (t) { var n = pages[t]; return n && Object.keys(n).indexOf('list') >= 0; });
        if (!tables.length) {
            // Nothing designed yet — fall back to the illustrative sample so the user isn't stuck.
            return DEFAULT_MENU.map(function (m) { return newItem({ label: m.label, icon: m.icon, type: m.type || 'hyperlink', children: (m.children || []).map(function (c) { return newItem({ label: c.label, icon: c.icon }); }) }); });
        }
        var items = [newItem({ label: 'Dashboard', icon: 'bi-speedometer2', link: { kind: 'page', page: '', subpage: 'list', url: '', params: '', target: 'self' } })];
        tables.forEach(function (t) {
            items.push(newItem({ label: cap(t), icon: 'bi-table', link: { kind: 'page', page: t, subpage: 'list', url: '', params: '', target: 'self' } }));
        });
        return items;
    }
    // SweetAlert dialog pre-themed to the current editor colours + stacked above
    // the Menu Editor modal (z-index).
    function swalOpts(extra) {
        var cs = getComputedStyle(document.body);
        var v = function (n, f) { return (cs.getPropertyValue(n) || '').trim() || f; };
        return Object.assign({
            background: v('--sp-surface', '#ffffff'),
            color: v('--sp-text', '#0f172a'),
            customClass: { container: 'sp-swal-top' }
        }, extra || {});
    }
    function resetMenuToDefault() {
        var pages = boot.designerPages || {};
        var hasPages = Object.keys(pages).some(function (t) { var n = pages[t]; return n && Object.keys(n).indexOf('list') >= 0; });
        function doReset() { S.menu.items = buildDefaultMenu(); menuSelId = S.menu.items[0] ? S.menu.items[0].id : null; afterMenuChange(); flash('Menu reset to default'); }
        var html = (hasPages
            ? 'This replaces the current menu with a fresh one built from your project pages — a <b>Dashboard</b> plus a link to every list page.'
            : 'No pages have been designed yet, so this loads a starter sample you can customise.')
            + '<br><span style="font-size:12px;opacity:.65">You can undo this (⌘Z).</span>';
        if (window.Swal) {
            Swal.fire(swalOpts({
                icon: 'warning', title: 'Reset menu to default?', html: html,
                showCancelButton: true, reverseButtons: true, focusCancel: true,
                confirmButtonText: 'Reset menu', cancelButtonText: 'Cancel',
                confirmButtonColor: '#e11d48',
                cancelButtonColor: (getComputedStyle(document.body).getPropertyValue('--sp-muted') || '').trim() || '#64748b'
            })).then(function (res) { if (res && res.isConfirmed) doReset(); });
        } else if (confirm('Reset the menu to a default built from your project pages?')) { doReset(); }
    }

    /* ---- duplicate an item (fresh ids, inserted right after the original) ---- */
    function cloneItemFresh(it) {
        var c = JSON.parse(JSON.stringify(it)); c.id = menuUid();
        if (c.children && c.children.length) c.children = c.children.map(cloneItemFresh); else c.children = c.children || [];
        return c;
    }
    function duplicateMenuItem(id) {
        var r = findItem(id); if (!r) return;
        var copy = cloneItemFresh(r.item);
        r.arr.splice(r.index + 1, 0, copy);
        menuSelId = copy.id;
        afterMenuChange();
    }

    /* ============================================================
       Menu Editor modal — a spacious 3-pane workspace:
       tree (left) · item editor (centre) · live role-aware preview (right).
       ============================================================ */
    function openMenuEditor() {
        if (!menuEd) menuEd = buildMenuEditor();
        if ((!menuSelId || !findItem(menuSelId)) && S.menu.items && S.menu.items.length) menuSelId = S.menu.items[0].id;
        menuFilterText = ''; var sb = menuEd.querySelector('#spMenuSearch'); if (sb) sb.value = '';
        historyInit();
        populateAsRole();
        renderMenuTree(); renderMenuDetail(); renderMenuMiniPreview(); renderMenuFoot();
        menuEd.removeAttribute('hidden');
        setTimeout(function () { var f = menuEd.querySelector('#it-label'); if (f) f.focus(); }, 40);
    }
    function closeMenuEditor() { if (menuEd) menuEd.setAttribute('hidden', ''); }
    function iconModalOpen() { return typeof icmEl !== 'undefined' && icmEl && !icmEl.hasAttribute('hidden'); }

    function buildMenuEditor() {
        var el = document.createElement('div'); el.className = 'sp-menued-back'; el.setAttribute('hidden', '');
        el.innerHTML =
            '<div class="sp-menued" role="dialog" aria-label="Menu Editor">' +
                '<header class="sp-menued-head">' +
                    '<div class="sp-menued-title"><span class="sp-menued-ic"><i class="bi bi-list-nested"></i></span>' +
                        '<div class="sp-menued-title-txt"><strong>Menu Editor</strong><small>Groups, links, icons &amp; role-based visibility</small></div></div>' +
                    '<div class="sp-menued-head-tools">' +
                        '<button type="button" class="sp-iconbtn" id="spMenuSpacing" data-tip="Menu spacing"><i class="bi bi-distribute-vertical"></i></button>' +
                        '<label class="sp-menued-asrole" data-tip="Preview the menu as a chosen role"><i class="bi bi-eye"></i><span>Preview as</span><select id="spMenuAsRole" class="sp-mini"></select></label>' +
                        '<button class="sp-modal-x" data-x aria-label="Close"><i class="bi bi-x-lg"></i></button>' +
                    '</div>' +
                '</header>' +
                '<div class="sp-menued-spacepanel" id="spMenuSpacePanel" hidden></div>' +
                '<div class="sp-menued-body">' +
                    '<section class="sp-menued-pane sp-menued-treepane">' +
                        '<div class="sp-menued-toolbar">' +
                            '<button type="button" class="sp-btn" id="spMenuAdd"><i class="bi bi-plus-lg"></i> Add link</button>' +
                            '<button type="button" class="sp-btn" id="spMenuAddSub"><i class="bi bi-folder-plus"></i> Add group</button>' +
                            '<button type="button" class="sp-iconbtn" id="spMenuAddDiv" data-tip="Add divider"><i class="bi bi-dash-lg"></i></button>' +
                            '<span style="flex:1"></span>' +
                            '<button type="button" class="sp-iconbtn" id="spMenuExpand" data-tip="Expand all"><i class="bi bi-arrows-expand"></i></button>' +
                            '<button type="button" class="sp-iconbtn" id="spMenuCollapse" data-tip="Collapse all"><i class="bi bi-arrows-collapse"></i></button>' +
                        '</div>' +
                        '<label class="sp-menued-search"><i class="bi bi-search"></i><input type="search" id="spMenuSearch" placeholder="Filter items…"></label>' +
                        '<div class="sp-menued-treewrap"><div class="sp-menu-tree" id="spMenuTree"></div></div>' +
                        '<div class="sp-menu-hint"><i class="bi bi-info-circle"></i> Drag to reorder · drop onto a group to nest.</div>' +
                    '</section>' +
                    '<section class="sp-menued-pane sp-menued-detailpane" id="spMenuDetail"></section>' +
                    '<section class="sp-menued-pane sp-menued-previewpane">' +
                        '<div class="sp-menued-pv-head"><i class="bi bi-window-sidebar"></i> Live preview</div>' +
                        '<div class="sp-mpv" id="spMenuMpv"></div>' +
                        '<div class="sp-menued-pv-hint">Shows what the selected role sees. Restricted items are hidden here just as they will be in the built app.</div>' +
                    '</section>' +
                '</div>' +
                '<footer class="sp-menued-foot">' +
                    '<button type="button" class="sp-menued-reset" id="spMenuReset" data-tip="Rebuild a default menu from your project pages"><i class="bi bi-arrow-counterclockwise"></i> Reset to Default</button>' +
                    '<span class="sp-menued-foot-info" id="spMenuFootInfo"></span><span style="flex:1"></span>' +
                    '<div class="sp-menued-undoredo">' +
                        '<button type="button" class="sp-iconbtn" id="spMenuUndo" data-tip="Undo (⌘Z)" disabled><i class="bi bi-arrow-counterclockwise"></i></button>' +
                        '<button type="button" class="sp-iconbtn" id="spMenuRedo" data-tip="Redo (⌘⇧Z)" disabled><i class="bi bi-arrow-clockwise"></i></button>' +
                    '</div>' +
                    '<span style="flex:1"></span>' +
                    '<button type="button" class="sp-btn primary" data-x><i class="bi bi-check-lg"></i> Done</button></footer>' +
            '</div>';
        document.body.appendChild(el);
        el.addEventListener('click', function (e) {
            if (e.target === el || e.target.closest('[data-x]')) { closeMenuEditor(); return; }
            var panel = el.querySelector('#spMenuSpacePanel');
            if (panel && !panel.hidden && !e.target.closest('#spMenuSpacePanel') && !e.target.closest('#spMenuSpacing')) panel.hidden = true;
        });
        el.querySelector('#spMenuSpacing').addEventListener('click', function (e) {
            e.stopPropagation();
            var p = el.querySelector('#spMenuSpacePanel');
            if (p.hidden) {
                renderSpacingPanel();
                // drop the panel from under its button but open it LEFTWARD and
                // clamp it so it never crosses into the live-preview pane.
                // Offsets are relative to .sp-menued (the panel's positioned parent).
                var mr = el.querySelector('.sp-menued').getBoundingClientRect(), br = this.getBoundingClientRect(), w = 252, gap = 8;
                var rightBound = mr.width - 12;
                var pv = el.querySelector('.sp-menued-previewpane');
                if (pv && getComputedStyle(pv).display !== 'none') rightBound = Math.min(rightBound, (pv.getBoundingClientRect().left - mr.left) - gap);
                var rightRel = Math.min(br.right - mr.left, rightBound);
                p.style.left = Math.max(12, rightRel - w) + 'px'; p.style.right = 'auto';
                p.style.top = (br.bottom - mr.top + 6) + 'px';
                p.hidden = false;
            } else p.hidden = true;
        });
        el.querySelector('#spMenuAdd').addEventListener('click', function () { var it = newItem(); S.menu.items.push(it); menuSelId = it.id; afterMenuChange(); focusLabel(); });
        el.querySelector('#spMenuAddSub').addEventListener('click', function () { var it = newItem({ type: 'submenu', label: 'New group', children: [] }); S.menu.items.push(it); menuSelId = it.id; afterMenuChange(); focusLabel(); });
        el.querySelector('#spMenuAddDiv').addEventListener('click', function () { var it = newDivider(); S.menu.items.push(it); menuSelId = it.id; afterMenuChange(); });
        el.querySelector('#spMenuExpand').addEventListener('click', function () { menuCollapsed = {}; renderMenuTree(); });
        el.querySelector('#spMenuCollapse').addEventListener('click', function () { (S.menu.items || []).forEach(function (it) { if (it.type === 'submenu' && it.children && it.children.length) menuCollapsed[it.id] = true; }); renderMenuTree(); });
        el.querySelector('#spMenuReset').addEventListener('click', resetMenuToDefault);
        el.querySelector('#spMenuUndo').addEventListener('click', menuUndoDo);
        el.querySelector('#spMenuRedo').addEventListener('click', menuRedoDo);
        el.querySelector('#spMenuSearch').addEventListener('input', function (e) { menuFilterText = (e.target.value || '').toLowerCase(); applyMenuFilter(); });
        el.querySelector('#spMenuAsRole').addEventListener('change', function (e) { menuPreviewRole = e.target.value; renderMenuMiniPreview(); });
        document.addEventListener('keydown', function (e) {
            if (!menuEd || menuEd.hasAttribute('hidden') || iconModalOpen()) return;
            if (e.key === 'Escape') { closeMenuEditor(); return; }
            // undo/redo — but let native editing win inside text fields
            var inField = /^(INPUT|TEXTAREA|SELECT)$/.test((e.target && e.target.tagName) || '');
            var mod = e.metaKey || e.ctrlKey;
            if (mod && !inField && (e.key === 'z' || e.key === 'Z')) { e.preventDefault(); if (e.shiftKey) menuRedoDo(); else menuUndoDo(); }
            else if (mod && !inField && (e.key === 'y' || e.key === 'Y')) { e.preventDefault(); menuRedoDo(); }
        });
        return el;
    }
    function focusLabel() { setTimeout(function () { var f = menuEd && menuEd.querySelector('#it-label'); if (f) { f.focus(); f.select(); } }, 30); }

    function renderMenuTree() {
        var host = menuEd && menuEd.querySelector('#spMenuTree'); if (!host) return;
        var items = S.menu.items || (S.menu.items = []);
        var rows = '';
        items.forEach(function (it) {
            var collapsed = !!menuCollapsed[it.id];
            rows += menuRow(it, 0, collapsed);
            if (!collapsed) (it.children || []).forEach(function (c) { rows += menuRow(c, 1); });
        });
        host.innerHTML = rows || '<div class="sp-menu-empty"><i class="bi bi-signpost-2" style="font-size:22px;opacity:.4"></i><br>No menu items yet.<br><button type="button" class="sp-btn primary" id="spMenuSeed" style="margin-top:10px"><i class="bi bi-magic"></i> Generate from pages</button></div>';
        var seedBtn = host.querySelector('#spMenuSeed'); if (seedBtn) seedBtn.addEventListener('click', function () { S.menu.items = buildDefaultMenu(); menuSelId = S.menu.items[0] ? S.menu.items[0].id : null; afterMenuChange(); });
        [].forEach.call(host.querySelectorAll('.sp-mi'), function (row) {
            var id = row.getAttribute('data-id');
            var tg = row.querySelector('[data-act="toggle"]');
            if (tg) tg.addEventListener('click', function (e) { e.stopPropagation(); menuCollapsed[id] = !menuCollapsed[id]; renderMenuTree(); });
            row.querySelector('[data-act="edit"]').addEventListener('click', function (e) { e.stopPropagation(); selectMenuItem(id); focusLabel(); });
            row.querySelector('[data-act="del"]').addEventListener('click', function (e) { e.stopPropagation(); deleteMenuItem(id); });
            row.addEventListener('click', function (e) { if (e.target.closest('button')) return; selectMenuItem(id); });
        });
        wireMenuDnd();
        applyMenuFilter();
        renderMenuFoot();
    }
    function selectMenuItem(id) {
        menuSelId = id;
        if (menuEd) [].forEach.call(menuEd.querySelectorAll('.sp-mi'), function (r) { r.classList.toggle('is-selected', r.getAttribute('data-id') === id); });
        renderMenuDetail();
    }
    function deleteMenuItem(id) {
        var r = findItem(id); if (!r) return;
        delete menuCollapsed[id]; r.arr.splice(r.index, 1);
        if (menuSelId === id) menuSelId = (S.menu.items[0] ? S.menu.items[0].id : null);
        afterMenuChange();
    }
    function applyMenuFilter() {
        if (!menuEd) return;
        var q = menuFilterText;
        [].forEach.call(menuEd.querySelectorAll('#spMenuTree .sp-mi'), function (row) {
            var lab = row.querySelector('.mi-label'); var txt = (lab ? lab.textContent : '').toLowerCase();
            row.style.display = (!q || txt.indexOf(q) >= 0) ? '' : 'none';
        });
    }
    function renderMenuFoot() {
        var f = menuEd && menuEd.querySelector('#spMenuFootInfo'); if (!f) return;
        var c = menuCounts();
        f.innerHTML = '<i class="bi bi-diagram-3"></i> ' + c.top + ' top-level · ' + c.groups + ' group' + (c.groups === 1 ? '' : 's') + ' · ' + c.links + ' link' + (c.links === 1 ? '' : 's') + (c.dividers ? ' · ' + c.dividers + ' divider' + (c.dividers === 1 ? '' : 's') : '');
    }

    /* ---- menu spacing panel (item height / padding / gap / divider gap) ---- */
    function renderSpacingPanel() {
        var p = menuEd && menuEd.querySelector('#spMenuSpacePanel'); if (!p) return;
        var s = menuSpacing();
        function row(key, label, lo, hi) {
            return '<div class="sp-space-row"><label>' + label + '<span class="sp-space-val" data-val="' + key + '">' + s[key] + 'px</span></label>' +
                '<input type="range" min="' + lo + '" max="' + hi + '" value="' + s[key] + '" data-space="' + key + '"></div>';
        }
        p.innerHTML =
            '<div class="sp-space-head"><strong><i class="bi bi-distribute-vertical"></i> Menu spacing</strong><button type="button" class="sp-space-reset" id="spSpaceReset">Reset</button></div>' +
            row('height', 'Item height', 26, 64) +
            row('padX', 'Item padding', 4, 28) +
            row('gap', 'Item spacing', 0, 16) +
            row('divGap', 'Divider spacing', 2, 28) +
            '<div class="sp-space-hint">Applies to the app sidebar. Grows nicely as your menu gets longer.</div>';
        [].forEach.call(p.querySelectorAll('input[data-space]'), function (inp) {
            inp.addEventListener('input', function () {
                var key = inp.getAttribute('data-space');
                S.menu.spacing = S.menu.spacing || {};
                S.menu.spacing[key] = parseInt(inp.value, 10);
                var v = p.querySelector('[data-val="' + key + '"]'); if (v) v.textContent = inp.value + 'px';
                renderMenuMiniPreview(); saveMenuSoon();
            });
        });
        p.querySelector('#spSpaceReset').addEventListener('click', function () {
            S.menu.spacing = Object.assign({}, DEFAULT_SPACING);
            renderSpacingPanel(); renderMenuMiniPreview(); saveMenuSoon();
        });
    }

    /* ---- detail / item editor pane ---- */
    function renderMenuDetail() {
        var host = menuEd && menuEd.querySelector('#spMenuDetail'); if (!host) return;
        var r = menuSelId ? findItem(menuSelId) : null;
        if (!r) {
            host.innerHTML = '<div class="sp-menued-empty"><i class="bi bi-hand-index"></i><p>Select a menu item to edit it,<br>or add a new link or group.</p></div>';
            return;
        }
        var it = r.item;
        if (it.type === 'divider') { renderDividerDetail(host, it); return; }
        var lk = it.link || (it.link = { kind: 'page', page: '', subpage: 'list', url: '', params: '', target: 'self' });
        var isSub = it.type === 'submenu';
        function radios(name, val, opts) { return opts.map(function (o) { return '<label class="sp-radio"><input type="radio" name="' + name + '" value="' + o[0] + '"' + (val === o[0] ? ' checked' : '') + '> ' + esc(o[1]) + '</label>'; }).join(''); }
        host.innerHTML =
            '<div class="sp-menued-detail-head"><span class="sp-menued-detail-ic" id="spDetIc">' + (it.icon ? iconHtml(it.icon) : '<i class="bi bi-' + (isSub ? 'folder2' : 'link-45deg') + '"></i>') + '</span>' +
                '<div class="sp-menued-detail-txt"><strong id="spDetTitle">' + esc(it.label || 'Item') + '</strong><small id="spDetKind">' + (isSub ? 'Group' : 'Link') + '</small></div>' +
                '<div class="sp-menued-detail-actions">' +
                    '<button type="button" class="sp-iconbtn" id="spDetDup" data-tip="Duplicate"><i class="bi bi-files"></i></button>' +
                    '<button type="button" class="sp-iconbtn danger" id="spDetDel" data-tip="Delete"><i class="bi bi-trash"></i></button>' +
                '</div></div>' +
            '<div class="sp-menued-detail-body">' +
                '<div class="sp-ctl"><label>Type</label><div class="sp-radios">' + radios('it-type', it.type, [['hyperlink', 'Link'], ['submenu', 'Group']]) + '</div></div>' +
                '<div class="sp-ctl"><label>Label</label><input type="text" id="it-label" value="' + esc(it.label || '') + '"></div>' +
                '<div class="sp-ctl"><label>Icon</label><span class="sp-cin"><span class="sp-icon-prev" id="it-icon-prev">' + (it.icon ? iconHtml(it.icon) : '<i class="bi bi-dash"></i>') + '</span><input type="text" id="it-icon" value="' + esc(it.icon || '') + '" placeholder="bi-star · fa-solid fa-star"><button type="button" class="sp-iconbtn" id="it-icon-browse" data-tip="Browse icons"><i class="bi bi-grid-3x3-gap"></i></button></span></div>' +
                '<div class="sp-ctl"><label>Show icon</label><div class="sp-radios">' + radios('it-showicon', it.showIcon || 'always', [['always', 'Always'], ['collapsed', 'Collapsed only']]) + '</div></div>' +
                '<div class="sp-menued-linksec" id="it-linksec">' +
                    '<div class="sp-ctl-group">Destination</div>' +
                    '<div class="sp-ctl"><label>Link type</label><div class="sp-radios">' + radios('it-linkkind', lk.kind || 'page', [['page', 'App page'], ['external', 'External URL']]) + '</div></div>' +
                    '<div class="sp-ctl" id="it-pagerow"><label>Link to</label><span class="sp-cin"><select id="it-page">' + pageOptions(lk.page || '') + '</select><select id="it-sub">' + subpageOptions(lk.page || '', lk.subpage || 'list') + '</select></span></div>' +
                    '<div class="sp-ctl" id="it-urlrow" hidden><label>URL</label><input type="text" id="it-url" value="' + esc(lk.url || '') + '" placeholder="https://…"></div>' +
                    '<div class="sp-ctl"><label>Params</label><input type="text" id="it-params" value="' + esc(lk.params || '') + '" placeholder="a=1&amp;b=2"></div>' +
                    '<label class="sp-checkrow"><input type="checkbox" id="it-target"' + (lk.target === 'blank' ? ' checked' : '') + '> Open in new window</label>' +
                '</div>' +
                '<div class="sp-ctl-group">Visibility &amp; permissions</div>' +
                '<div class="sp-menued-roles" id="it-roles"></div>' +
                '<div class="sp-ctl-group">Advanced</div>' +
                '<div class="sp-ctl"><label>Custom CSS</label><input type="text" id="it-css" value="' + esc(it.css || '') + '" placeholder="color:#f00;font-weight:700"></div>' +
            '</div>';

        var linksec = host.querySelector('#it-linksec'); if (linksec) linksec.style.display = isSub ? 'none' : '';

        function setIconPreviews() {
            host.querySelector('#it-icon-prev').innerHTML = it.icon ? iconHtml(it.icon) : '<i class="bi bi-dash"></i>';
            var dic = host.querySelector('#spDetIc'); if (dic) dic.innerHTML = it.icon ? iconHtml(it.icon) : '<i class="bi bi-' + (it.type === 'submenu' ? 'folder2' : 'link-45deg') + '"></i>';
            var row = menuEd.querySelector('.sp-mi[data-id="' + it.id + '"] .mi-ico'); if (row) row.innerHTML = it.icon ? iconHtml(it.icon) : '<i class="bi bi-' + (it.type === 'submenu' ? 'folder2' : 'link-45deg') + '" style="opacity:.4"></i>';
        }
        function liveLight() { renderMenuMiniPreview(); renderMenu(); applyPreview(); saveMenuSoon(); }

        host.querySelector('#spDetDup').addEventListener('click', function () { duplicateMenuItem(it.id); });
        host.querySelector('#spDetDel').addEventListener('click', function () { deleteMenuItem(it.id); });
        [].forEach.call(host.querySelectorAll('input[name=it-type]'), function (rb) { rb.addEventListener('change', function () {
            it.type = host.querySelector('input[name=it-type]:checked').value;
            if (it.type === 'submenu' && !it.children) it.children = [];
            afterMenuChange();
        }); });
        host.querySelector('#it-label').addEventListener('input', function (e) {
            it.label = e.target.value;
            var t = host.querySelector('#spDetTitle'); if (t) t.textContent = it.label || 'Item';
            var row = menuEd.querySelector('.sp-mi[data-id="' + it.id + '"] .mi-label'); if (row) row.textContent = it.label || 'Item';
            liveLight();
        });
        host.querySelector('#it-icon').addEventListener('input', function (e) { it.icon = e.target.value; setIconPreviews(); liveLight(); });
        host.querySelector('#it-icon-browse').addEventListener('click', function () {
            openIconModal(it.icon || '', function (cls) { it.icon = cls; var inp = host.querySelector('#it-icon'); if (inp) inp.value = cls; setIconPreviews(); liveLight(); });
        });
        [].forEach.call(host.querySelectorAll('input[name=it-showicon]'), function (rb) { rb.addEventListener('change', function () { it.showIcon = host.querySelector('input[name=it-showicon]:checked').value; saveMenuSoon(); }); });
        [].forEach.call(host.querySelectorAll('input[name=it-linkkind]'), function (rb) { rb.addEventListener('change', function () {
            it.link.kind = host.querySelector('input[name=it-linkkind]:checked').value;
            host.querySelector('#it-pagerow').hidden = it.link.kind !== 'page';
            host.querySelector('#it-urlrow').hidden = it.link.kind !== 'external';
            saveMenuSoon();
        }); });
        host.querySelector('#it-page').addEventListener('change', function (e) { it.link.page = e.target.value; host.querySelector('#it-sub').innerHTML = subpageOptions(e.target.value, 'list'); it.link.subpage = 'list'; saveMenuSoon(); });
        host.querySelector('#it-sub').addEventListener('change', function (e) { it.link.subpage = e.target.value; saveMenuSoon(); });
        host.querySelector('#it-url').addEventListener('input', function (e) { it.link.url = e.target.value; saveMenuSoon(); });
        host.querySelector('#it-params').addEventListener('input', function (e) { it.link.params = e.target.value; saveMenuSoon(); });
        host.querySelector('#it-target').addEventListener('change', function (e) { it.link.target = e.target.checked ? 'blank' : 'self'; saveMenuSoon(); });
        host.querySelector('#it-css').addEventListener('input', function (e) { it.css = e.target.value; saveMenuSoon(); });
        // sync link-kind rows to the saved value
        host.querySelector('#it-pagerow').hidden = (lk.kind || 'page') !== 'page';
        host.querySelector('#it-urlrow').hidden = (lk.kind || 'page') !== 'external';

        renderRolesEditor(it);
    }

    /* ---- divider editor (5 preset styles + optional heading text) ---- */
    function renderDividerDetail(host, it) {
        var ds = it.dividerStyle || 'line';
        host.innerHTML =
            '<div class="sp-menued-detail-head"><span class="sp-menued-detail-ic"><i class="bi bi-dash-lg"></i></span>' +
                '<div class="sp-menued-detail-txt"><strong>Divider</strong><small>Separator</small></div>' +
                '<div class="sp-menued-detail-actions">' +
                    '<button type="button" class="sp-iconbtn" id="spDetDup" data-tip="Duplicate"><i class="bi bi-files"></i></button>' +
                    '<button type="button" class="sp-iconbtn danger" id="spDetDel" data-tip="Delete"><i class="bi bi-trash"></i></button>' +
                '</div></div>' +
            '<div class="sp-menued-detail-body">' +
                '<div class="sp-ctl-group">Divider style</div>' +
                '<div class="sp-div-picker" id="it-divstyle">' + DIVIDER_STYLES.map(function (s) {
                    return '<button type="button" class="sp-div-opt' + (s.id === ds ? ' is-active' : '') + '" data-div="' + s.id + '" data-tip="' + esc(s.hint) + '">' +
                        '<span class="sp-div-opt-prev">' + (s.id === 'label' ? '<span class="sp-div-opt-heading">Section</span>' : '<span class="sp-div-rule sp-div-' + s.id + '"></span>') + '</span>' +
                        '<span class="sp-div-opt-lbl">' + esc(s.label) + '</span></button>';
                }).join('') + '</div>' +
                '<div class="sp-ctl" id="it-divlabelrow"' + (ds === 'label' ? '' : ' hidden') + '><label>Heading</label><input type="text" id="it-divlabel" value="' + esc(it.label || '') + '" placeholder="e.g. Administration"></div>' +
                '<div class="sp-ctl-group">Visibility &amp; permissions</div>' +
                '<div class="sp-menued-roles" id="it-roles"></div>' +
                '<div class="sp-menued-pv-hint" style="padding-top:6px">Dividers are non-clickable separators. Role rules hide them just like links.</div>' +
            '</div>';
        host.querySelector('#spDetDup').addEventListener('click', function () { duplicateMenuItem(it.id); });
        host.querySelector('#spDetDel').addEventListener('click', function () { deleteMenuItem(it.id); });
        [].forEach.call(host.querySelectorAll('.sp-div-opt'), function (b) {
            b.addEventListener('click', function () {
                it.dividerStyle = b.getAttribute('data-div');
                [].forEach.call(host.querySelectorAll('.sp-div-opt'), function (x) { x.classList.toggle('is-active', x === b); });
                var lr = host.querySelector('#it-divlabelrow'); if (lr) lr.hidden = it.dividerStyle !== 'label';
                renderMenuTree(); renderMenuMiniPreview(); saveMenuSoon();
                if (it.dividerStyle === 'label') { var i = host.querySelector('#it-divlabel'); if (i) i.focus(); }
            });
        });
        var dl = host.querySelector('#it-divlabel');
        if (dl) dl.addEventListener('input', function (e) {
            it.label = e.target.value;
            var row = menuEd.querySelector('.sp-mi[data-id="' + it.id + '"] .mi-div-heading'); if (row) row.textContent = it.label || 'Section';
            renderMenuMiniPreview(); saveMenuSoon();
        });
        renderRolesEditor(it);
    }

    /* ---- per-item role visibility editor (tags) ---- */
    function collectUsedRoles() {
        var set = {};
        (function walk(arr) { (arr || []).forEach(function (it) { (it.roles || []).forEach(function (r) { set[r] = 1; }); walk(it.children); }); })(S.menu.items || []);
        return Object.keys(set).sort();
    }
    // Real access vocabulary defined for the project (Security → Groups / roles).
    // These flow in from the server (boot.roleVocab); as the in-app group &
    // permission manager lands, its groups/roles appear here automatically.
    function accessVocab() {
        var rv = boot.roleVocab || {};
        var groups = (rv.groups || []).map(function (g) { return typeof g === 'string' ? g : (g && g.name) || ''; }).filter(Boolean);
        var roles = (rv.roles || []).map(function (r) { return String(r || ''); }).filter(Boolean);
        return { groups: groups, roles: roles };
    }
    function renderRolesEditor(it) {
        var host = menuEd && menuEd.querySelector('#it-roles'); if (!host) return;
        var roles = it.roles || (it.roles = []);
        var chips = roles.map(function (r, i) { return '<span class="sp-tag">' + esc(r) + '<button type="button" class="sp-tag-x" data-i="' + i + '" aria-label="Remove ' + esc(r) + '">&times;</button></span>'; }).join('');
        var vocab = accessVocab();
        var free = function (r) { return roles.indexOf(r) < 0; };
        var groupSug = vocab.groups.filter(free);
        var roleSug = vocab.roles.filter(free).filter(function (r) { return vocab.groups.indexOf(r) < 0; });
        var known = vocab.groups.concat(vocab.roles);
        var otherSug = collectUsedRoles().filter(function (r) { return free(r) && known.indexOf(r) < 0; });
        var hasReal = vocab.groups.length || vocab.roles.length;
        if (!hasReal) otherSug = ROLE_SUGGESTIONS.filter(free).concat(otherSug.filter(function (r) { return ROLE_SUGGESTIONS.indexOf(r) < 0; }));
        function sugBtn(r, ic) { return '<button type="button" class="sp-tag-add" data-role="' + esc(r) + '"><i class="bi ' + ic + '"></i>' + esc(r) + '</button>'; }
        function sugRow(label, arr, ic) { return arr.length ? '<div class="sp-tags-sgroup"><span class="sp-tags-slabel">' + label + '</span>' + arr.map(function (r) { return sugBtn(r, ic); }).join('') + '</div>' : ''; }
        var sugHtml = sugRow('Groups', groupSug, 'bi-folder2') + sugRow('Roles', roleSug, 'bi-person-badge') + sugRow(hasReal ? 'Other' : 'Suggested', otherSug, 'bi-plus');
        host.innerHTML =
            '<div class="sp-menued-roles-mode">' +
                '<label class="sp-radio"><input type="radio" name="it-vis" value="all"' + (roles.length ? '' : ' checked') + '> <i class="bi bi-globe2"></i> Everyone</label>' +
                '<label class="sp-radio"><input type="radio" name="it-vis" value="roles"' + (roles.length ? ' checked' : '') + '> <i class="bi bi-shield-lock"></i> Specific roles</label>' +
            '</div>' +
            '<div class="sp-tags-wrap" id="it-tagswrap"' + (roles.length ? '' : ' hidden') + '>' +
                '<div class="sp-tags" id="it-tags">' + chips + '<input type="text" class="sp-tags-input" id="it-roleinput" placeholder="Type a role or group…"></div>' +
                (sugHtml ? '<div class="sp-tags-suggest">' + sugHtml + '</div>' : '') +
                '<div class="sp-tags-hint">Only users in one of these ' + (hasReal ? 'groups/roles' : 'roles') + ' will see this ' + (it.type === 'submenu' ? 'group' : 'link') + '. Leave empty for everyone.' + (hasReal ? '' : ' <span class="sp-tags-hint-dim">Define groups in Security → Groups to pick them here.</span>') + '</div>' +
            '</div>';
        function rolesChanged() { renderMenuTree(); renderMenuMiniPreview(); populateAsRole(); renderMenu(); applyPreview(); saveMenuSoon(); }
        function focusInput() { var i = menuEd.querySelector('#it-roleinput'); if (i) i.focus(); }
        function addRole(v) { v = (v || '').trim(); if (!v) return; if (it.roles.indexOf(v) < 0) it.roles.push(v); renderRolesEditor(it); rolesChanged(); focusInput(); }
        [].forEach.call(host.querySelectorAll('input[name=it-vis]'), function (rb) { rb.addEventListener('change', function () {
            var v = host.querySelector('input[name=it-vis]:checked').value;
            if (v === 'all') { it.roles = []; renderRolesEditor(it); rolesChanged(); }
            else { host.querySelector('#it-tagswrap').hidden = false; focusInput(); }
        }); });
        [].forEach.call(host.querySelectorAll('.sp-tag-x'), function (b) { b.addEventListener('click', function () { it.roles.splice(+b.getAttribute('data-i'), 1); renderRolesEditor(it); rolesChanged(); focusInput(); }); });
        [].forEach.call(host.querySelectorAll('.sp-tag-add'), function (b) { b.addEventListener('click', function () { addRole(b.getAttribute('data-role')); }); });
        var input = host.querySelector('#it-roleinput');
        if (input) {
            input.addEventListener('keydown', function (e) {
                if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addRole(input.value); }
                else if (e.key === 'Backspace' && input.value === '' && it.roles.length) { it.roles.pop(); renderRolesEditor(it); rolesChanged(); focusInput(); }
            });
            input.addEventListener('blur', function () { if (input.value.trim()) addRole(input.value); });
        }
    }

    /* ---- "Preview as" role selector + the role-aware mini preview ---- */
    function populateAsRole() {
        var sel = menuEd && menuEd.querySelector('#spMenuAsRole'); if (!sel) return;
        var vocab = accessVocab(), used = collectUsedRoles();
        var seen = {};
        function optList(arr) { return arr.filter(function (r) { if (seen[r]) return false; seen[r] = 1; return true; }).map(function (r) { return '<option value="' + esc(r) + '">' + esc(r) + '</option>'; }).join(''); }
        var known = {};
        vocab.groups.concat(vocab.roles).concat(used).forEach(function (r) { known[r] = 1; });
        if (menuPreviewRole !== '__all' && menuPreviewRole !== '__guest' && !known[menuPreviewRole]) menuPreviewRole = '__all';
        var opts = '<option value="__all">Everyone (all items)</option>';
        var g = optList(vocab.groups); if (g) opts += '<optgroup label="Groups">' + g + '</optgroup>';
        var r = optList(vocab.roles); if (r) opts += '<optgroup label="Roles">' + r + '</optgroup>';
        var c = optList(used); if (c) opts += '<optgroup label="Custom">' + c + '</optgroup>';
        opts += '<option value="__guest">Signed-out guest</option>';
        sel.innerHTML = opts;
        sel.value = menuPreviewRole;
    }
    function menuRoleVisible(it) {
        var roles = it.roles || [];
        if (!roles.length) return true;
        if (menuPreviewRole === '__all') return true;
        if (menuPreviewRole === '__guest') return false;
        return roles.indexOf(menuPreviewRole) >= 0;
    }
    function menuFilterPreview(items) {
        var out = [];
        (items || []).forEach(function (it) {
            if (!menuRoleVisible(it)) return;
            if (it.type === 'submenu') {
                var kids = menuFilterPreview(it.children || []);
                if (!kids.length) return;
                var copy = {}; for (var k in it) copy[k] = it[k]; copy.children = kids; out.push(copy);
            } else out.push(it);
        });
        return out;
    }
    function renderMenuMiniPreview() {
        var host = menuEd && menuEd.querySelector('#spMenuMpv'); if (!host) return;
        var items = menuFilterPreview(S.menu.items || []);
        var brand = String((S.current && S.current.shell && S.current.shell.logo && S.current.shell.logo.text) || 'App');
        var html = '<div class="sp-mpv-brand"><span class="sp-mpv-logo">' + esc(brand.charAt(0).toUpperCase()) + '</span><span class="sp-mpv-brand-t">' + esc(brand) + '</span></div>';
        if (!items.length) {
            html += '<div class="sp-mpv-empty"><i class="bi bi-eye-slash"></i> Nothing visible to this role.</div>';
        } else {
            var mpvDivider = function (it) {
                var d = it.dividerStyle || 'line';
                return d === 'label' ? '<div class="sp-mpv-divlabel">' + esc(it.label || 'Section') + '</div>' : '<div class="sp-mpv-divider"><span class="sp-div-rule sp-div-' + d + '"></span></div>';
            };
            var firstLink = items.findIndex ? items.findIndex(function (x) { return x.type !== 'divider'; }) : 0;
            var sp = menuSpacing();
            html += '<div class="sp-mpv-nav" style="--mpv-gap:' + sp.gap + 'px;--mpv-h:' + sp.height + 'px;--mpv-padx:' + sp.padX + 'px;--mpv-divgap:' + sp.divGap + 'px">' + items.map(function (it, i) {
                if (it.type === 'divider') return mpvDivider(it);
                var ic = '<span class="mi-i">' + iconHtml(it.icon || (it.type === 'submenu' ? 'bi-folder2' : 'bi-circle')) + '</span>';
                if (it.type === 'submenu') {
                    var subs = (it.children || []).map(function (c) { return c.type === 'divider' ? mpvDivider(c) : '<div class="sp-mpv-item sub"><span class="mi-i">' + iconHtml(c.icon || 'bi-dot') + '</span><span class="lbl">' + esc(c.label || 'Item') + '</span></div>'; }).join('');
                    return '<div class="sp-mpv-item' + (i === firstLink ? ' on' : '') + '">' + ic + '<span class="lbl">' + esc(it.label || 'Group') + '</span><i class="bi bi-chevron-down sp-mpv-caret"></i></div>' + subs;
                }
                return '<div class="sp-mpv-item' + (i === firstLink ? ' on' : '') + '">' + ic + '<span class="lbl">' + esc(it.label || 'Item') + '</span></div>';
            }).join('') + '</div>';
        }
        host.innerHTML = html;
    }

    // drag/drop: reorder among siblings + drop onto a submenu row to nest
    var dragId = null;
    function wireMenuDnd() {
        [].forEach.call($('spMenuTree').querySelectorAll('.sp-mi'), function (row) {
            row.addEventListener('dragstart', function (e) { dragId = row.getAttribute('data-id'); row.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; });
            row.addEventListener('dragend', function () { row.classList.remove('dragging'); [].forEach.call($('spMenuTree').querySelectorAll('.sp-mi'), function (r) { r.classList.remove('drop-before', 'drop-after', 'drop-into'); }); });
            row.addEventListener('dragover', function (e) {
                e.preventDefault();
                var tgt = row.getAttribute('data-id'); if (tgt === dragId) return;
                var t = findItem(tgt); var into = t && t.item.type === 'submenu' && !row.classList.contains('child');
                var rect = row.getBoundingClientRect(); var after = (e.clientY - rect.top) > rect.height / 2;
                row.classList.remove('drop-before', 'drop-after', 'drop-into');
                row.classList.add(into && (e.clientX - rect.left) > rect.width * 0.5 ? 'drop-into' : (after ? 'drop-after' : 'drop-before'));
            });
            row.addEventListener('dragleave', function () { row.classList.remove('drop-before', 'drop-after', 'drop-into'); });
            row.addEventListener('drop', function (e) {
                e.preventDefault();
                var tgtId = row.getAttribute('data-id'); if (tgtId === dragId) return;
                var mode = row.classList.contains('drop-into') ? 'into' : (row.classList.contains('drop-after') ? 'after' : 'before');
                moveItem(dragId, tgtId, mode); dragId = null; afterMenuChange();
            });
        });
    }
    function moveItem(srcId, tgtId, mode) {
        var s = findItem(srcId); if (!s) return;
        var moved = s.arr.splice(s.index, 1)[0];
        var t = findItem(tgtId); if (!t) { S.menu.items.push(moved); return; }
        if (mode === 'into' && t.item.type === 'submenu') { t.item.children = t.item.children || []; t.item.children.push(moved); moved.children = []; }
        else { var idx = t.index + (mode === 'after' ? 1 : 0); t.arr.splice(idx, 0, moved); }
    }

    /* ---- link destination option helpers (shared by the Menu Editor detail pane) ---- */
    function pageOptions(sel) {
        var pages = Object.keys(boot.designerPages || {});
        return '<option value=""' + (sel === '' ? ' selected' : '') + '>Home / menu</option>' + pages.map(function (p) { return '<option value="' + esc(p) + '"' + (p === sel ? ' selected' : '') + '>' + esc(p) + '</option>'; }).join('');
    }
    function subpageOptions(page, sel) {
        var node = boot.designerPages && boot.designerPages[page];
        var types = node ? Object.keys(node) : ['list', 'add', 'edit', 'view'];
        return types.map(function (t) { return '<option value="' + esc(t) + '"' + (t === sel ? ' selected' : '') + '>' + esc(t) + '</option>'; }).join('');
    }
    /* ---- icon browser (self-contained; reuses .ppd-icm CSS + the designer /icons endpoint) ---- */
    var icmEl = null, icmState = { onPick: null, pack: '', seq: 0, timer: null };
    var PACK_LABEL = { 'fontawesome-pro': 'FontAwesome Pro', 'bootstrap-icons': 'Bootstrap', 'material-symbols': 'Material' };
    function iconEndpoint(q, pack, limit) {
        return fetch(base + '/project/' + encodeURIComponent(slug) + '/designer/icons?q=' + encodeURIComponent(q || '') + (pack ? '&pack=' + encodeURIComponent(pack) : '') + '&limit=' + (limit || 400), { credentials: 'same-origin' }).then(function (r) { return r.json(); });
    }
    function buildIconModal() {
        var el = document.createElement('div'); el.className = 'ppd-icm-backdrop'; el.setAttribute('hidden', '');
        el.innerHTML = '<div class="ppd-icm"><header class="ppd-icm-head"><div><strong>Choose an icon</strong><small>FontAwesome Pro · Bootstrap · Material</small></div><button class="ppd-icm-close" data-x><i class="bi bi-x-lg"></i></button></header>' +
            '<div class="ppd-icm-toolbar"><label class="ppd-icm-search"><i class="bi bi-search"></i><input type="search" data-search placeholder="Search icons…"></label>' +
            '<div class="ppd-icm-packs" data-packs><button class="is-active" data-pack="">All</button><button data-pack="fontawesome-pro">FontAwesome</button><button data-pack="bootstrap-icons">Bootstrap</button><button data-pack="material-symbols">Material</button></div></div>' +
            '<div class="ppd-icm-body"><div class="ppd-icm-grid" data-grid></div></div><footer class="ppd-icm-foot"><span data-count></span><span class="ppd-icm-esc">Esc to close</span></footer></div>';
        document.body.appendChild(el);
        var grid = el.querySelector('[data-grid]'), search = el.querySelector('[data-search]'), count = el.querySelector('[data-count]');
        function close() { el.setAttribute('hidden', ''); }
        el.addEventListener('click', function (e) { if (e.target === el || e.target.closest('[data-x]')) 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) { if (icmState.onPick) icmState.onPick(b.getAttribute('data-iconval')); close(); } });
        el.querySelector('[data-packs]').addEventListener('click', function (e) { var b = e.target.closest('[data-pack]'); if (!b) return; [].forEach.call(el.querySelectorAll('[data-pack]'), function (x) { x.classList.toggle('is-active', x === b); }); icmState.pack = b.getAttribute('data-pack'); load(); });
        search.addEventListener('input', load);
        function load() {
            var q = search.value.trim(); clearTimeout(icmState.timer); count.textContent = 'Loading…'; var mine = ++icmState.seq;
            icmState.timer = setTimeout(function () {
                iconEndpoint(q, icmState.pack, q ? 600 : 900).then(function (res) {
                    if (mine !== icmState.seq) return; var groups = (res && res.groups) || [];
                    grid.innerHTML = groups.map(function (g) {
                        return '<div class="ppd-icm-sec">' + esc(PACK_LABEL[g.pack] || g.pack) + '</div>' + (g.icons || []).map(function (ic) {
                            var cls = ic.className || ic.name; return '<button class="ppd-icm-ic" data-iconval="' + esc(cls) + '" title="' + esc(cls) + '"><span class="ppd-icm-ic-glyph">' + iconHtml(cls) + '</span><span class="ppd-icm-ic-lbl">' + esc(ic.label || '') + '</span></button>';
                        }).join('');
                    }).join('') || '<div class="ppd-icm-empty">No icons</div>';
                    count.textContent = (res.total || 0) + ' icons';
                }).catch(function () { count.textContent = 'Search unavailable'; });
            }, q ? 200 : 0);
        }
        el._open = function () { search.value = ''; icmState.pack = ''; [].forEach.call(el.querySelectorAll('[data-pack]'), function (x) { x.classList.toggle('is-active', x.getAttribute('data-pack') === ''); }); el.removeAttribute('hidden'); load(); setTimeout(function () { search.focus(); }, 30); };
        return el;
    }
    function openIconModal(current, onPick) { icmState.onPick = onPick; if (!icmEl) icmEl = buildIconModal(); icmEl._open(); }

    /* ---- Palette tab ------------------------------------------------------
       Shows EVERY --pp-* custom property the theme compiles to, resolved to a
       literal value, for the mode currently previewed. The values come from the
       server's ThemeCompiler (POST /style/compile) rather than being recomputed
       here, so what a designer sees is exactly what the build emits — the ~76
       derived tokens included. */
    var palState = { seq: 0, cache: null };

    function renderPalette() {
        var host = $('spPalette');
        if (!host) return;
        if (!S.current) { host.innerHTML = ''; return; }

        if (!palState.cache) {
            host.innerHTML = '<p class="sp-pal-note">Compiling palette…</p>';
        }
        var seq = ++palState.seq;
        post('/compile', { theme: appearanceOf(S.current), scope: ':root' }).then(function (res) {
            if (seq !== palState.seq) return;          // a newer edit won
            if (!res || !res.success) { host.innerHTML = '<p class="sp-pal-note">Could not compile this theme.</p>'; return; }
            palState.cache = res;
            paintPalette(host, res);
        }).catch(function () {
            if (seq === palState.seq) host.innerHTML = '<p class="sp-pal-note">Could not compile this theme.</p>';
        });
    }

    function paintPalette(host, res) {
        var mode   = S.mode === 'dark' ? 'dark' : 'light';
        var vals   = (res.palette && res.palette[mode]) || {};
        var groups = res.catalogue || {};
        var docs   = res.docs || {};
        var authored = {};
        (res.authored || []).forEach(function (p) { authored[p] = true; });

        var nAuthored = 0, total = 0;
        Object.keys(groups).forEach(function (g) {
            groups[g].forEach(function (p) {
                if (vals[p] === undefined) return;
                total++; if (authored[p]) nAuthored++;
            });
        });

        // ── header: what this panel is, plus live counts and a filter ──
        var html =
            '<div class="sp-pal-head">' +
              '<div class="sp-pal-lede">' +
                '<h3>Design tokens</h3>' +
                '<p>Every CSS custom property this theme compiles to, resolved for <strong>' + mode + '</strong> mode. ' +
                'The generated app styles <em>everything</em> from these — no component hardcodes a colour, so a theme change re-skins the whole UI instantly.</p>' +
              '</div>' +
              '<div class="sp-pal-stats">' +
                '<div class="sp-pal-stat"><b>' + total + '</b><span>resolved</span></div>' +
                '<div class="sp-pal-stat is-authored"><b>' + nAuthored + '</b><span>authored</span></div>' +
                '<div class="sp-pal-stat is-derived"><b>' + (total - nAuthored) + '</b><span>derived</span></div>' +
              '</div>' +
            '</div>' +
            '<div class="sp-pal-legend">' +
              '<span><i class="dot authored"></i><strong>Authored</strong> — you set these on the <em>Theme</em> tab.</span>' +
              '<span><i class="dot derived"></i><strong>Derived</strong> — computed from the authored ones, and contrast-checked.</span>' +
              '<span class="sp-pal-hint"><i class="bi bi-clipboard"></i>Click any token to copy its name.</span>' +
            '</div>' +
            '<div class="sp-pal-tools"><input type="search" id="spPalFind" class="sp-pal-find" ' +
              'placeholder="Filter tokens — try &quot;row&quot;, &quot;focus&quot;, &quot;sidebar&quot;…" autocomplete="off"></div>';

        Object.keys(groups).forEach(function (g) {
            var props = groups[g].filter(function (p) { return vals[p] !== undefined; });
            if (!props.length) return;
            html += '<section class="sp-pal-group" data-group="' + esc(g.toLowerCase()) + '">' +
                    '<h4>' + esc(g) + '<span class="sp-pal-count">' + props.length + '</span></h4>' +
                    '<div class="sp-pal-grid">';
            props.forEach(function (p) {
                var v     = String(vals[p]);
                var name  = p.replace(/^--pp-/, '');
                var isCol = /^(#|rgb|hsl)/i.test(v);
                var isAu  = !!authored[p];
                var doc   = docs[p] || '';
                html += '<button type="button" class="sp-pal-item' + (isCol ? '' : ' is-text') + (isAu ? ' is-authored' : '') + '"' +
                          ' data-copy="' + esc(p) + '" data-find="' + esc((name + ' ' + v + ' ' + doc).toLowerCase()) + '">' +
                          '<span class="sp-pal-sw">' + (isCol ? '<i style="background:' + esc(v) + '"></i>'
                                                             : '<em>' + esc(shortForm(v)) + '</em>') + '</span>' +
                          '<span class="sp-pal-meta">' +
                            '<span class="sp-pal-name">' + esc(name) +
                              (isAu ? '<i class="sp-pal-tag" title="Authored on the Theme tab">A</i>' : '') + '</span>' +
                            '<span class="sp-pal-val">' + esc(v) + '</span>' +
                            (doc ? '<span class="sp-pal-doc">' + esc(doc) + '</span>' : '') +
                          '</span>' +
                        '</button>';
            });
            html += '</div></section>';
        });
        html += '<p class="sp-pal-empty" hidden>No token matches that filter.</p>';
        host.innerHTML = html;
        bindPalette(host);
    }

    /** Compact stand-in shown in the swatch for non-colour tokens. */
    function shortForm(v) {
        if (/^\d/.test(v)) return v.replace(/\s.*$/, '');   // 250px, 8px …
        if (/shadow|rgba?\(/i.test(v)) return '◍';
        return 'Aa';
    }

    function bindPalette(host) {
        // click-to-copy the property name — the thing you actually paste into CSS
        host.addEventListener('click', function (e) {
            var b = e.target.closest('.sp-pal-item'); if (!b) return;
            var prop = b.getAttribute('data-copy');
            var done = function () {
                b.classList.add('copied');
                setTimeout(function () { b.classList.remove('copied'); }, 900);
            };
            if (navigator.clipboard && navigator.clipboard.writeText) {
                navigator.clipboard.writeText('var(' + prop + ')').then(done, done);
            } else { done(); }
        });
        var find = host.querySelector('#spPalFind');
        if (!find) return;
        find.addEventListener('input', function () {
            var q = find.value.trim().toLowerCase();
            var any = false;
            [].forEach.call(host.querySelectorAll('.sp-pal-group'), function (sec) {
                var shown = 0;
                [].forEach.call(sec.querySelectorAll('.sp-pal-item'), function (it) {
                    var hit = !q || it.getAttribute('data-find').indexOf(q) !== -1;
                    it.hidden = !hit; if (hit) shown++;
                });
                sec.hidden = shown === 0;
                if (shown) any = true;
            });
            host.querySelector('.sp-pal-empty').hidden = any;
        });
    }

    function renderTab() {
        if (S.tab === 'theme') renderTokens();
        else if (S.tab === 'layout') renderLayout();
        else if (S.tab === 'grid') renderGrid();
        else if (S.tab === 'menu') renderMenu();
        else if (S.tab === 'css') renderCss();
        else if (S.tab === 'palette') renderPalette();
    }
    function setTab(tab) {
        S.tab = tab;
        [].forEach.call(document.querySelectorAll('#spMainTabs button'), function (b) { b.classList.toggle('active', b.getAttribute('data-tab') === tab); });
        [].forEach.call(document.querySelectorAll('.sp-tab'), function (t) { t.hidden = t.getAttribute('data-tab') !== tab; });
        renderTab();
    }

    function renderAll() { renderList(); renderMeta(); renderTab(); applyLock(); applyPreview(); }

    /**
     * Make "New" self-explanatory: spell out the next step, put the caret in the
     * name field and highlight it. Previously the editor simply swapped to an
     * unnamed draft with no cue about what to do.
     */
    function focusNameForNew(msg) {
        var el = $('spName');
        if (!el) return;
        setTimeout(function () {
            el.focus(); el.select();
            el.classList.add('is-asking');
            setTimeout(function () { el.classList.remove('is-asking'); }, 2400);
            if (typeof spTip !== 'undefined' && spTip.show) {
                spTip.show(el, msg || 'Name your theme, then press Save.');
                setTimeout(function () { spTip.hide(); }, 3200);
            }
        }, 40);
    }

    /* ---- actions ---- */
    function post(path, body) {
        return fetch(base + '/project/' + encodeURIComponent(slug) + '/style' + path, {
            method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify(body || {})
        }).then(function (r) { return r.json(); });
    }
    function refresh(res) { if (res && res.themes) { S.themes = res.themes; S.def = res.default; index(); } }

    function appearanceOf(d) {
        return { tokens: clone(d ? d.tokens : {}), dark: clone(d ? d.dark : {}), shell: clone(d ? (d.shell || {}) : {}), grid: clone(d ? (d.grid || {}) : {}), density: (d && d.density) || 'normal', surface: (d && d.surface) || 'flat', css: (d && d.css) || '' };
    }
    function doNew() {
        var d = S.byId[S.def] || S.byId['default'] || S.themes[0];
        S.current = Object.assign({ id: '', label: '', description: '', custom: true, isNew: true }, appearanceOf(d));
        S.mode = 'light'; setModeTabs(); S.dirty = true; renderAll();
        focusNameForNew('New theme — type a name here, then Save.');
    }
    function doDuplicate() {
        var t = S.current; if (!t) return;
        S.current = Object.assign({ id: '', label: (t.label || 'Theme') + ' copy', description: t.description || '', custom: true, isNew: true }, appearanceOf(t));
        S.dirty = true; renderAll();
        focusNameForNew('Editable copy — rename it, then Save.');
    }
    function idFromLabel(l) { return String(l || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); }
    function doSave() {
        var t = S.current; if (!t) return;
        // A locked built-in cannot be saved over — Save becomes Duplicate.
        if (isReadonly()) { doDuplicate(); focusNameForNew('Give your copy a name, then Save.'); return; }
        var id = t.id || idFromLabel($('spName').value) || ('theme-' + Date.now());
        var theme = { id: id, label: $('spName').value || id, description: t.description || '', tokens: t.tokens || {}, dark: t.dark || {}, shell: t.shell || {}, grid: t.grid || {}, density: t.density || 'normal', css: t.css || '' };
        $('spSave').disabled = true;
        post('/save', { theme: theme, menu: S.menu }).then(function (res) {
            refresh(res); S.current = clone(S.byId[id] || theme); S.current.isNew = false; S.dirty = false; renderAll();
            flash('Saved “' + theme.label + '”');
        }).catch(function () { flash('Save failed', true); $('spSave').disabled = false; });
    }
    function doDelete() {
        var t = S.current; if (!t || !t.custom || t.isNew) return;
        var go = function () {
            post('/delete', { id: t.id }).then(function (res) { refresh(res); selectTheme(S.def); flash('Deleted'); })
                .catch(function () { flash('Delete failed', true); });
        };
        if (window.Swal) {
            Swal.fire({
                icon: 'warning', title: 'Delete theme?',
                html: 'Delete “' + String(t.label).replace(/[&<>"]/g, function (c) { return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]; }) + '”? This can’t be undone.',
                showCancelButton: true, confirmButtonText: 'Delete', confirmButtonColor: '#e11d48'
            }).then(function (res) { if (res && res.isConfirmed) go(); });
        } else if (confirm('Delete theme “' + t.label + '”?')) { go(); }
    }
    function doDefault() {
        var t = S.current; if (!t || t.isNew) return;
        post('/save', { default: t.id }).then(function (res) { refresh(res); renderList(); flash('“' + t.label + '” is now the default'); })
            .catch(function () { flash('Could not set default', true); });
    }

    function doExport() {
        var t = S.current; if (!t) return;
        var data = { id: t.id || idFromLabel($('spName').value), label: $('spName').value || t.label, tokens: t.tokens || {}, dark: t.dark || {}, shell: t.shell || {}, grid: t.grid || {}, density: t.density || 'normal', css: t.css || '' };
        var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
        var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = (data.id || 'theme') + '.json'; a.click();
        setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
        flash('Exported ' + (data.id || 'theme') + '.json');
    }
    function doImport(file) {
        var r = new FileReader();
        r.onload = function () {
            try {
                var d = JSON.parse(r.result);
                S.current = { id: '', label: (d.label || 'Imported') + '', description: d.description || '', tokens: d.tokens || {}, dark: d.dark || {}, custom: true, isNew: true };
                S.mode = 'light'; setModeTabs(); S.dirty = true; renderAll(); flash('Imported — review &amp; Save');
            } catch (e) { flash('Invalid theme JSON', true); }
        };
        r.readAsText(file);
    }

    var flashT = null;
    function flash(msg, bad) {
        var el = $('spFlash'); if (!el) { el = document.createElement('div'); el.id = 'spFlash'; el.style.cssText = 'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:9999;padding:10px 18px;border-radius:10px;font-size:13px;font-weight:600;color:#fff;box-shadow:0 10px 30px rgba(2,6,23,.3)'; document.body.appendChild(el); }
        el.style.background = bad ? '#dc2626' : '#16a34a'; el.textContent = msg; el.style.opacity = '1';
        clearTimeout(flashT); flashT = setTimeout(function () { el.style.opacity = '0'; el.style.transition = 'opacity .4s'; }, 1800);
    }

    function setModeTabs() {
        [].forEach.call($('spModeTabs').querySelectorAll('button'), function (b) { b.classList.toggle('active', b.getAttribute('data-mode') === S.mode); });
    }

    function seg(id, attr, cb) {
        [].forEach.call($(id).querySelectorAll('button'), function (b) {
            b.addEventListener('click', function () {
                [].forEach.call($(id).querySelectorAll('button'), function (x) { x.classList.toggle('active', x === b); });
                cb(b.getAttribute(attr));
            });
        });
    }

    /* ---- boot ---- */
    function init() {
        S.themes = Array.isArray(boot.themes) ? boot.themes : []; index();
        [].forEach.call($('spMainTabs').querySelectorAll('button'), function (b) { b.addEventListener('click', function () { setTab(b.getAttribute('data-tab')); }); });
        $('spNew').addEventListener('click', doNew);
        $('spDup').addEventListener('click', doDuplicate);
        $('spSave').addEventListener('click', doSave);
        $('spDelete').addEventListener('click', doDelete);
        $('spDefault').addEventListener('click', doDefault);
        $('spExport').addEventListener('click', doExport);
        $('spImport').addEventListener('click', function () { $('spImportFile').click(); });
        $('spImportFile').addEventListener('change', function (e) { if (e.target.files[0]) doImport(e.target.files[0]); e.target.value = ''; });
        $('spName').addEventListener('input', function () { if (S.current) { S.current.label = $('spName').value; S.dirty = true; renderList(); } });
        [].forEach.call($('spModeTabs').querySelectorAll('button'), function (b) {
            b.addEventListener('click', function () { S.mode = b.getAttribute('data-mode'); setModeTabs(); renderTokens(); applyPreview(); if (S.tab === 'palette') { palState.cache ? paintPalette($('spPalette'), palState.cache) : renderPalette(); } });
        });
        // preview source / device / table / page controls
        seg('spSourceSeg', 'data-src', function (v) { S.src = v; applyPreview(); });
        seg('spDeviceSeg', 'data-dev', function (v) { S.device = v; applyPreview(); });
        $('spTable').addEventListener('change', function () {
            var v = $('spTable').value, i = v.indexOf(':');
            if (i < 0) return;
            S.srcKind = v.slice(0, i); var id = v.slice(i + 1);
            if (S.srcKind === 'table') S.table = id; else S.srcId = id;
            if (S.src !== 'real') { S.src = 'real'; [].forEach.call(document.querySelectorAll('#spSourceSeg button'), function (b) { b.classList.toggle('active', b.getAttribute('data-src') === 'real'); }); }
            populatePages(); applyPreview();
        });
        $('spPage').addEventListener('change', function () { S.pageType = $('spPage').value; applyPreview(); });
        wireResizers();
        populateSources();
        selectTheme(S.def && S.byId[S.def] ? S.def : (S.themes[0] && S.themes[0].id));
        // Deep-link: /project/<slug>/style#menu-editor opens the Menu Editor
        // directly (used by the "Menu Editor" button in the Tables module).
        if (/(^|#)menu-editor$/.test(location.hash)) { setTab('menu'); setTimeout(openMenuEditor, 60); }
    }
    // draggable dividers → resize the theme list + editor columns in real time
    function wireResizers() {
        var ws = document.querySelector('.sp-workspace');
        var LIMITS = { list: [150, 340], editor: [260, 560] };
        try { var saved = JSON.parse(localStorage.getItem('sp_widths') || '{}'); if (saved.list) ws.style.setProperty('--sp-list-w', saved.list + 'px'); if (saved.editor) ws.style.setProperty('--sp-editor-w', saved.editor + 'px'); } catch (e) { }
        [].forEach.call(document.querySelectorAll('.sp-resizer'), function (rz) {
            var which = rz.getAttribute('data-resize'), varName = which === 'list' ? '--sp-list-w' : '--sp-editor-w';
            rz.addEventListener('mousedown', function (e) {
                e.preventDefault(); rz.classList.add('dragging'); document.body.classList.add('sp-resizing');
                var startX = e.clientX, startW = parseInt(getComputedStyle(ws).getPropertyValue(varName), 10) || (which === 'list' ? 200 : 312);
                function move(ev) { var w = Math.max(LIMITS[which][0], Math.min(LIMITS[which][1], startW + (ev.clientX - startX))); ws.style.setProperty(varName, w + 'px'); }
                function up() {
                    document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up);
                    rz.classList.remove('dragging'); document.body.classList.remove('sp-resizing');
                    try { var w = { list: parseInt(getComputedStyle(ws).getPropertyValue('--sp-list-w'), 10), editor: parseInt(getComputedStyle(ws).getPropertyValue('--sp-editor-w'), 10) }; localStorage.setItem('sp_widths', JSON.stringify(w)); } catch (e) { }
                }
                document.addEventListener('mousemove', move); document.addEventListener('mouseup', up);
            });
        });
    }
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
})();
