/*
 * app-mobile.js — companion to app-mobile.css for GENERATED PPPHP apps.
 * The native-app behaviour layer. Phones only (≤767.98px) except the PWA/service
 * worker (all sizes); everything idles/removes on larger screens.
 *
 * MODULAR BY DESIGN: each feature is its own idempotent function, gated by a
 * config flag, so one can be changed/disabled without touching the others. A
 * future "Mobile" settings page just emits `window.PP_MOBILE = {…}` before this
 * file loads to toggle features — nothing else changes.
 *
 *   window.PP_MOBILE = {
 *     // shell + navigation
 *     tabbar:true, edgeNav:true, edgeSwipe:true, swipeActions:true,
 *     skeleton:true, pwa:true, haptics:true, pageTransition:true,
 *     // Phase A — component mobilisation
 *     inputEnhance:true, nativePickers:true, cardify:true, liveSearch:true,
 *     stickyHeaders:true, emptyStates:true, toasts:true, sheets:true,
 *     darkMode:'off',              // 'off' | 'auto' | 'light' | 'dark'
 *     // Phase B — signature interactions
 *     pullRefresh:true, infiniteScroll:false, modalDetail:false,
 *     viewTransitions:true, kpiCarousel:true,
 *     // Phase C — platform depth
 *     offline:false, push:false, shareTarget:false, installPrompt:true, appBadge:true,
 *     formDrafts:true,
 *     flavours:{ list:'auto'|'cards'|'table', dates:'custom'|'native',
 *                selects:'native'|'sheet', numbers:'plain'|'stepper' }
 *   }
 *
 * Adding a feature = one CFG flag + one idempotent init() gated on it. That is the
 * "Mobile Control Registry" principle in the JS layer: new treatment, never a rewrite.
 */
(function () {
    'use strict';
    var MQ = '(max-width: 767.98px)';
    var CFG = Object.assign({
        tabbar: true, edgeNav: true, edgeSwipe: true, swipeActions: true,
        skeleton: true, pwa: true, haptics: true, pageTransition: true,
        inputEnhance: true, nativePickers: true, cardify: true, liveSearch: true,
        stickyHeaders: true, emptyStates: true, toasts: true, sheets: true,
        darkMode: 'off',
        pullRefresh: true, infiniteScroll: false, modalDetail: false,
        viewTransitions: true, kpiCarousel: true,
        offline: false, push: false, shareTarget: false, installPrompt: true, appBadge: true,
        formDrafts: true,
        // Per-component "flavour" matrix (set from the Mobile SPA). Each key picks how
        // one component family presents on phones; honoured by the init*() below.
        flavours: { list: 'auto', dates: 'custom', selects: 'native', numbers: 'plain' }
    }, (window.PP_MOBILE || {}));
    var FLV = CFG.flavours || {};

    function phone() { return window.matchMedia(MQ).matches; }
    function reduceMotion() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function standalone() { return window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true; }
    function haptic(ms) {
        if (!CFG.haptics || !navigator.vibrate) return;
        if (navigator.userActivation && !navigator.userActivation.isActive) return;
        try { navigator.vibrate(ms || 8); } catch (e) {}
    }
    function appBase() {
        var m = document.querySelector('link[rel="manifest"]');
        if (m && m.href) { try { return m.href.replace(/\/[^/]*$/, ''); } catch (e) {} }
        return (window.PP_BASE || '').replace(/\/+$/, '');
    }

    /* ─────────── 1. Bottom tab bar ─────────── */
    function iconClass(a) { var i = a.querySelector('i.bi, i[class*="bi-"], i'); return i ? i.className : 'bi bi-dot'; }
    function labelOf(a) { var s = a.querySelector('.pp-menu-lbl, span'); return ((s ? s.textContent : a.textContent) || '').trim(); }
    function samePath(href) {
        try { return new URL(href, location.href).pathname === location.pathname; } catch (e) { return false; }
    }
    function normIcon(ic) { ic = (ic || '').trim(); return /^bi-/.test(ic) ? ('bi ' + ic) : (/\bbi\b/.test(ic) ? ic : 'bi bi-' + (ic || 'dot')); }
    function addTab(bar, opts) {
        var tab = document.createElement('a');
        tab.className = 'ppm-tab' + (opts.active ? ' is-active' : '');
        tab.href = opts.href || '#';
        var i = document.createElement('i'); i.className = normIcon(opts.icon); i.setAttribute('aria-hidden', 'true');
        var s = document.createElement('span'); s.textContent = opts.label || '';
        tab.appendChild(i); tab.appendChild(s);
        tab.addEventListener('click', function () { haptic(); });
        bar.appendChild(tab);
    }

    function buildTabbar() {
        if (!CFG.tabbar || !phone()) { var e = document.querySelector('.ppm-tabbar'); if (e) { e.remove(); document.body.classList.remove('ppm-has-tabbar'); } return; }
        if (document.querySelector('.ppm-tabbar')) return;

        var tc = CFG.tabbarCfg || {};
        var base = appBase();
        var resolve = function (link) { if (!link) return '#'; if (/^https?:/i.test(link)) return link; return base + (link.charAt(0) === '/' ? '' : '/') + link; };

        var bar = document.createElement('nav');
        bar.className = 'ppm-tabbar fx-' + (tc.effect || 'none') + (tc.showLabels === false ? ' no-labels' : '');
        bar.setAttribute('aria-label', 'Primary');

        var built = 0;
        if (tc.mode === 'custom' && tc.items && tc.items.length) {
            tc.items.forEach(function (it) {
                if (it.enabled === false) return;
                var href = resolve(it.link);
                addTab(bar, { icon: it.icon, label: it.label, href: href, active: samePath(href) });
                built++;
            });
        } else {
            var count = Math.max(2, Math.min(5, tc.count || 4));
            var links = [].slice.call(document.querySelectorAll('.pp-sidebar-nav .pp-menu > li > a.pp-menu-link'))
                .filter(function (a) { var h = a.getAttribute('href'); return h && h !== '#'; });
            links.slice(0, count).forEach(function (a) {
                addTab(bar, { icon: iconClass(a), label: labelOf(a), href: a.getAttribute('href'), active: a.classList.contains('pp-menu-active') || samePath(a.getAttribute('href')) });
                built++;
            });
        }
        if (!built) return;

        var more = document.createElement('button');
        more.type = 'button'; more.className = 'ppm-tab ppm-tab-more';
        more.innerHTML = '<i class="bi bi-grid-3x3-gap" aria-hidden="true"></i><span>More</span>';
        more.addEventListener('click', function () { haptic(); var b = document.getElementById('ppBurger'); if (b) b.click(); });
        bar.appendChild(more);

        document.body.appendChild(bar);
        document.body.classList.add('ppm-has-tabbar');
    }

    /* ─────────── 2. Back / Forward edge tabs ─────────── */
    // Can we actually go back / forward? Prefer the Navigation API (precise, Chromium);
    // fall back to history.length for Back and a session flag for Forward (iOS Safari has
    // no "can go forward" signal). Both are false on a fresh app load → buttons hidden.
    function navState() {
        if (window.navigation && 'canGoBack' in window.navigation) {
            return { back: !!window.navigation.canGoBack, fwd: !!window.navigation.canGoForward };
        }
        var fwd = false; try { fwd = sessionStorage.getItem('ppmFwd') === '1'; } catch (e) {}
        return { back: history.length > 1, fwd: fwd };
    }
    function refreshNavButtons() {
        var s = navState();
        var l = document.querySelector('.ppm-navedge-left'), r = document.querySelector('.ppm-navedge-right');
        if (l) l.style.display = s.back ? '' : 'none';
        if (r) r.style.display = s.fwd ? '' : 'none';
    }
    function goBack() { try { sessionStorage.setItem('ppmFwd', '1'); } catch (e) {} history.back(); }
    function buildNavButtons() {
        if (!CFG.edgeNav || !phone()) { [].forEach.call(document.querySelectorAll('.ppm-navedge'), function (e) { e.remove(); }); return; }
        // Apply appearance from the Mobile settings page (size/bg/colour/opacity).
        var ec = CFG.edgeNavCfg || {}; var rs = document.documentElement.style;
        var sz = Math.max(40, Math.min(80, parseInt(ec.size, 10) || 52));
        rs.setProperty('--ppm-edge-h', sz + 'px');
        rs.setProperty('--ppm-edge-w', Math.round(sz * 0.5) + 'px');
        if (ec.bg) rs.setProperty('--ppm-edge-bg', ec.bg);
        if (ec.iconColor) rs.setProperty('--ppm-edge-color', ec.iconColor);
        if (ec.opacity != null && ec.opacity !== '') rs.setProperty('--ppm-edge-op', String(ec.opacity));
        if (!document.querySelector('.ppm-navedge')) {
            var tab = function (side, icon, fn) {
                var b = document.createElement('button'); b.type = 'button';
                b.className = 'ppm-navedge ppm-navedge-' + side;
                b.setAttribute('aria-label', side === 'left' ? 'Back' : 'Forward');
                b.style.display = 'none';                 // hidden until refreshNavButtons decides
                b.innerHTML = '<i class="bi bi-chevron-' + icon + '" aria-hidden="true"></i>';
                b.addEventListener('click', function () { haptic(); fn(); });
                document.body.appendChild(b);
            };
            tab('left', 'left', goBack);
            tab('right', 'right', function () { history.forward(); });
        }
        refreshNavButtons();
    }

    /* ─────────── 3. Edge-swipe: left→Back, right→Forward ─────────── */
    function initEdgeSwipe() {
        if (!CFG.edgeSwipe || window.__ppmSwipe) return; window.__ppmSwipe = true;
        var EDGE = 28, THRESH = 72, startX = 0, startY = 0, mode = null, active = false, ind = null;
        function indicator() {
            if (ind) return ind;
            ind = document.createElement('div'); ind.className = 'ppm-edge-ind';
            ind.innerHTML = '<i class="bi bi-chevron-left" aria-hidden="true"></i>';
            document.body.appendChild(ind); return ind;
        }
        document.addEventListener('touchstart', function (e) {
            mode = null; active = false;
            if (!phone() || e.touches.length !== 1) return;
            var t = e.touches[0]; startX = t.clientX; startY = t.clientY;
            if (startX <= EDGE) mode = 'back'; else if (startX >= window.innerWidth - EDGE) mode = 'fwd';
        }, { passive: true });
        document.addEventListener('touchmove', function (e) {
            if (!mode) return;
            var t = e.touches[0], dx = t.clientX - startX, dy = t.clientY - startY;
            if (Math.abs(dy) > Math.abs(dx)) { mode = null; if (ind) ind.classList.remove('show'); return; }
            var prog = mode === 'back' ? dx / THRESH : -dx / THRESH;
            if (prog <= 0) return; prog = Math.min(1, prog);
            if (!active && prog > 0.12) {
                active = true; var d = indicator();
                d.querySelector('i').className = 'bi bi-chevron-' + (mode === 'back' ? 'right' : 'left');
                d.style.left = mode === 'back' ? '0px' : 'auto';
                d.style.right = mode === 'fwd' ? '0px' : 'auto';
                d.classList.add('show');
            }
            if (active) {
                var travel = Math.min(Math.abs(t.clientX - startX), THRESH + 24);
                ind.style.transform = 'translateY(-50%) translateX(' + (mode === 'back' ? travel : -travel) + 'px)';
                ind.style.opacity = String(0.45 + prog * 0.55);
                ind.classList.toggle('ready', prog >= 1);
                if (e.cancelable) e.preventDefault();
            }
        }, { passive: false });
        document.addEventListener('touchend', function () {
            if (mode && active && ind) {
                var go = ind.classList.contains('ready');
                ind.classList.remove('show', 'ready'); ind.style.opacity = '0'; ind.style.transform = 'translateY(-50%)';
                if (go) { haptic(12); if (mode === 'back') goBack(); else history.forward(); }
            }
            mode = null; active = false;
        }, { passive: true });
    }

    /* ─────────── 4. Swipe-to-reveal actions on list cards ─────────── */
    function initSwipeActions() {
        if (!CFG.swipeActions || !phone()) return;
        function tx(el) { var m = getComputedStyle(el).transform, v = m && m.match(/matrix\(([^)]+)\)/); return v ? (parseFloat(v[1].split(',')[4]) || 0) : 0; }
        function closeAll(except) { [].forEach.call(document.querySelectorAll('.pp-card.ppm-swipeable'), function (c) { if (c !== except && c.__ppmClose) c.__ppmClose(); }); }
        [].forEach.call(document.querySelectorAll('.pp-card'), function (card) {
            if (card.__ppmSwipe) return;
            var foot = card.querySelector('.pp-card-foot');
            var acts = foot ? [].slice.call(foot.querySelectorAll('a[href],button')) : [];
            if (!acts.length) return;
            card.__ppmSwipe = true; card.classList.add('ppm-swipeable');

            var panel = document.createElement('div'); panel.className = 'ppm-swipe-actions';
            acts.forEach(function (el) {
                var i = el.querySelector('i'), txt = (el.textContent || '').trim();
                var isDel = /delete|trash|remove/i.test(el.className + ' ' + (el.getAttribute('data-pp-btn') || '') + ' ' + (i ? i.className : ''));
                var b = document.createElement(el.tagName === 'A' ? 'a' : 'button');
                if (el.tagName === 'A') b.href = el.getAttribute('href'); else { b.type = 'button'; b.addEventListener('click', function () { el.click(); if (card.__ppmClose) card.__ppmClose(); }); }
                b.className = 'ppm-swipe-act' + (isDel ? ' danger' : '');
                b.innerHTML = (i ? '<i class="' + i.className + '"></i>' : '') + '<span>' + (txt || (isDel ? 'Delete' : 'Open')) + '</span>';
                panel.appendChild(b);
            });
            var surface = document.createElement('div'); surface.className = 'ppm-swipe-surface';
            while (card.firstChild) surface.appendChild(card.firstChild);
            card.appendChild(panel); card.appendChild(surface);

            var sx = 0, sy = 0, drag = false, open = false, W = 0;
            card.__ppmClose = function () { surface.style.transform = 'translateX(0)'; open = false; };
            surface.addEventListener('touchstart', function (e) { if (e.touches.length !== 1) return; sx = e.touches[0].clientX; sy = e.touches[0].clientY; drag = true; W = panel.offsetWidth || 132; }, { passive: true });
            surface.addEventListener('touchmove', function (e) {
                if (!drag) return; var dx = e.touches[0].clientX - sx, dy = e.touches[0].clientY - sy;
                if (Math.abs(dy) > Math.abs(dx)) { drag = false; return; }
                var t = Math.max(-W - 12, Math.min(0, (open ? -W : 0) + dx));
                surface.style.transform = 'translateX(' + t + 'px)';
                if (e.cancelable && Math.abs(dx) > 6) e.preventDefault();
            }, { passive: false });
            surface.addEventListener('touchend', function () {
                if (!drag) return; drag = false;
                if (tx(surface) < -W / 2) { surface.style.transform = 'translateX(' + (-W) + 'px)'; open = true; haptic(); closeAll(card); }
                else card.__ppmClose();
            }, { passive: true });
        });
        document.addEventListener('touchstart', function (e) { if (!e.target.closest('.ppm-swipeable')) closeAll(null); }, { passive: true });
    }

    /* ─────────── 5. Skeleton loader on navigation ─────────── */
    function initSkeleton() {
        if (!CFG.skeleton) return;
        var overlay = null, timer = null;
        function hide() { if (overlay) { overlay.remove(); overlay = null; } if (timer) { clearTimeout(timer); timer = null; } }
        function show() {
            if (overlay || !phone()) return;
            overlay = document.createElement('div'); overlay.className = 'ppm-skeleton';
            var cards = '';
            for (var i = 0; i < 6; i++) cards += '<div class="ppm-sk-card"><div class="ppm-sk-line w55"></div><div class="ppm-sk-line w85"></div><div class="ppm-sk-line w35"></div></div>';
            overlay.innerHTML = cards;
            document.body.appendChild(overlay);
            timer = setTimeout(hide, 6000);          // failsafe if navigation is cancelled
        }
        document.addEventListener('click', function (e) {
            if (!phone()) return;
            var a = e.target.closest('a[href]'); if (!a) return;
            if (e.defaultPrevented) return;
            var href = a.getAttribute('href');
            if (!href || href.charAt(0) === '#' || /^(javascript:|mailto:|tel:)/i.test(href)) return;
            if (a.target === '_blank' || a.hasAttribute('download') || a.hasAttribute('data-no-skeleton')) return;
            if (a.closest('[data-pp-view],[data-pp-ov],[data-bs-toggle],.ppd-view-toggle,.pp-cols-toggle')) return;
            try { if (new URL(a.href, location.href).origin !== location.origin) return; } catch (x) { return; }
            show();
        }, true);
        document.addEventListener('submit', function () { if (phone()) show(); }, true);
        window.addEventListener('pageshow', hide);   // back/forward (bfcache) restore
    }

    /* ─────────── 6. PWA: register the service worker (all sizes) ─────────── */
    function initPWA() {
        if (!CFG.pwa || !('serviceWorker' in navigator)) return;
        window.addEventListener('load', function () {
            var base = appBase();
            navigator.serviceWorker.register(base + '/sw.js').catch(function () {});
        });
    }

    /* ═══════════════════════════════════════════════════════════════════════
       PHASE A — component mobilisation (progressive enhancement of real DOM)
       ═══════════════════════════════════════════════════════════════════════ */
    function el(tag, cls, html) { var e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; }

    /* ─────────── 7. System dark mode (opt-in) ─────────── */
    function initColorScheme() {
        var mode = CFG.darkMode || 'off';
        if (mode === 'off') return;
        var root = document.documentElement;
        function apply(dark) { root.classList.toggle('is-dark', !!dark); }
        if (mode === 'dark') return apply(true);
        if (mode === 'light') return apply(false);
        // auto → follow the OS, live.
        var mq = window.matchMedia('(prefers-color-scheme: dark)');
        apply(mq.matches);
        try { mq.addEventListener('change', function (e) { apply(e.matches); }); }
        catch (x) { try { mq.addListener(function (e) { apply(e.matches); }); } catch (y) {} }
    }

    /* ─────────── 8. Native keyboards / input hints ─────────── */
    function enhanceInputs() {
        if (!CFG.inputEnhance || !phone()) return;
        var CUSTOM = '.pp-picker,.pp-uploader,.pp-rte,.pp-tags,.pp-color,.flatpickr-input,.pp-time24,[data-pp-time24],.ppm-no-enhance';
        var fields = document.querySelectorAll('input:not([type=hidden]):not([type=file]):not([type=checkbox]):not([type=radio]), textarea');
        [].forEach.call(fields, function (inp) {
            if (inp.__ppmInput || inp.closest(CUSTOM) || inp.readOnly) return;
            inp.__ppmInput = true;
            var name = ((inp.name || '') + ' ' + (inp.id || '') + ' ' + (inp.getAttribute('data-pp-field') || '')).toLowerCase();
            var type = (inp.getAttribute('type') || 'text').toLowerCase();
            function set(a, v) { if (!inp.hasAttribute(a)) inp.setAttribute(a, v); }
            if (type === 'email' || /\bemail\b/.test(name)) { if (type === 'text') inp.type = 'email'; set('inputmode', 'email'); set('autocapitalize', 'none'); set('autocomplete', 'email'); set('spellcheck', 'false'); }
            else if (type === 'tel' || /\b(phone|tel|mobile|contact|whatsapp)\b/.test(name)) { if (type === 'text') inp.type = 'tel'; set('inputmode', 'tel'); set('autocomplete', 'tel'); }
            else if (type === 'url' || /\b(url|website|link)\b/.test(name)) { if (type === 'text') inp.type = 'url'; set('inputmode', 'url'); set('autocapitalize', 'none'); set('spellcheck', 'false'); }
            else if (type === 'number' || /\b(amount|price|qty|quantity|total|count|number|age|zip|postal|pin)\b/.test(name)) { set('inputmode', /\b(price|amount|total|rate|cost)\b/.test(name) ? 'decimal' : 'numeric'); }
            else if (type === 'search' || /\b(search|query)\b/.test(name)) { set('inputmode', 'search'); set('enterkeyhint', 'search'); set('autocapitalize', 'none'); }
            else if (type === 'password') { set('autocapitalize', 'none'); set('autocomplete', 'current-password'); }
            else if (type === 'text' && /\bname\b/.test(name)) { set('autocapitalize', 'words'); }
        });
        // enterkeyhint: last visible text field in a form → "done", others → "next".
        [].forEach.call(document.querySelectorAll('form'), function (form) {
            var list = [].filter.call(form.querySelectorAll('input:not([type=hidden]):not([type=checkbox]):not([type=radio]):not([type=file]), textarea, select'),
                function (f) { return f.offsetParent !== null && !f.disabled && !f.readOnly; });
            list.forEach(function (f, i) { if (f.tagName === 'TEXTAREA' || f.getAttribute('enterkeyhint')) return; f.setAttribute('enterkeyhint', i === list.length - 1 ? 'done' : 'next'); });
        });
    }

    /* ─────────── 9. Card-ify wide data tables on phones ─────────── */
    function cardifyTables() {
        if (!CFG.cardify || !phone()) return;
        var lf = FLV.list || 'auto';
        if (lf === 'table') return;                       // owner chose a scrollable table
        [].forEach.call(document.querySelectorAll('.ppd-grid-table'), function (table) {
            if (table.__ppmCardify) return;
            var heads = [].map.call(table.querySelectorAll('thead th'), function (th) {
                var lbl = th.querySelector('.ppd-th-label');
                var node = (lbl || th).cloneNode(true);   // don't mutate the real header
                [].forEach.call(node.querySelectorAll('i,svg,button,select,input,.ppd-th-menu,.ppd-sort'), function (n) { n.remove(); });
                return (node.textContent || '').replace(/\s+/g, ' ').replace(/[▲▼▴▾↑↓◀▶]/g, '').trim();
            });
            if (lf !== 'cards' && heads.length < 3) return;   // 'cards' forces even narrow tables
            [].forEach.call(table.querySelectorAll('tbody tr'), function (tr) {
                [].forEach.call(tr.children, function (td, i) {
                    if (td.hasAttribute('data-label')) return;
                    var h = heads[i] || '';
                    if (h) td.setAttribute('data-label', h);
                    if (/select|checkbox|actions?|^$/i.test(h) || td.querySelector('input[type=checkbox],.pp-act-more,.ppd-row-actions')) td.classList.add('ppm-cell-plain');
                    // a master-detail ("DETAILS") cell with no actual link (e.g. the inline-add
                    // row) is a dummy on mobile — hide it rather than show an empty labelled row.
                    if (td.querySelector('.pp-md-exp, .pp-md-popup') && !td.querySelector('a[href]') && !/[a-z0-9]/i.test(td.textContent || '')) td.classList.add('ppm-cell-hide');
                });
            });
            table.classList.add('ppm-cardified');
            var wrap = table.closest('.ppd-grid-table-wrap'); if (wrap) wrap.classList.add('ppm-cardified-wrap');
            table.__ppmCardify = true;
        });
    }

    /* ─────────── 9b. Row-action / More menus: give icon-only items a real label ───────────
       The row-actions and toolbar-More buttons are icon-only (label lives in
       aria-label/data-pp-tip, shown as a desktop tooltip). In a bottom sheet that
       reads as a blank pill, so inject the label text as a real span. */
    function enhanceMenuLabels() {
        if (!phone()) return;
        [].forEach.call(document.querySelectorAll('.pp-act-pop, .pp-more-menu'), function (pop) {
            [].forEach.call(pop.querySelectorAll(':scope > a, :scope > button'), function (btn) {
                if (btn.__ppmLabeled) return; btn.__ppmLabeled = true;
                btn.classList.add('ppm-act-row');
                if ((btn.textContent || '').trim()) return;           // already has visible text
                var label = btn.getAttribute('aria-label') || btn.getAttribute('data-pp-tip') || '';
                if (label) { var s = document.createElement('span'); s.className = 'ppm-act-label'; s.textContent = label; btn.appendChild(s); }
            });
        });
    }

    /* ─────────── 10. Instant client-side list search ─────────── */
    function initLiveSearch() {
        if (!CFG.liveSearch || !phone()) return;
        var box = document.querySelector('.pp-toolbar input[type=search], .pp-toolbar input[name=q], .pp-toolbar input[name=search], input.pp-search-input, #ppSearch, .pp-search input');
        if (!box || box.__ppmSearch) return;
        box.__ppmSearch = true;
        if ((box.getAttribute('type') || 'text') === 'text') box.type = 'search';
        var rows = function () {
            var r = [].slice.call(document.querySelectorAll('.ppd-grid-table.ppm-cardified tbody tr, .ppd-grid-table tbody tr'));
            if (!r.length) r = [].slice.call(document.querySelectorAll('.ppd-cards .pp-card'));
            return r;
        };
        var t = null;
        box.addEventListener('input', function () {
            if (t) clearTimeout(t);
            t = setTimeout(function () {
                var q = box.value.trim().toLowerCase();
                var list = rows(), shown = 0;
                list.forEach(function (row) {
                    var hit = !q || (row.textContent || '').toLowerCase().indexOf(q) > -1;
                    row.style.display = hit ? '' : 'none'; if (hit) shown++;
                });
                toggleEmpty(shown === 0 && q !== '', q);
            }, 200);
        });
    }
    function toggleEmpty(show, q) {
        var host = document.querySelector('.ppd-grid-table-wrap, .ppd-cards, .pp-list-body, .pp-content'); if (!host) return;
        var e = document.getElementById('ppmSearchEmpty');
        if (show) {
            if (!e) { e = el('div', 'ppm-empty', '<i class="bi bi-search" aria-hidden="true"></i><h3>No matches</h3><p></p>'); e.id = 'ppmSearchEmpty'; host.parentNode.insertBefore(e, host.nextSibling); }
            e.querySelector('p').textContent = 'Nothing matches “' + q + '”.'; e.style.display = '';
        } else if (e) { e.style.display = 'none'; }
    }

    /* ─────────── 11. Friendly empty states ─────────── */
    function initEmptyStates() {
        if (!CFG.emptyStates || !phone()) return;
        if (document.querySelector('.pp-empty, .ppd-empty, .no-records, .ppm-empty')) return;
        var tbody = document.querySelector('.ppd-grid-table tbody');
        var cards = document.querySelector('.ppd-cards');
        var count = tbody ? tbody.querySelectorAll('tr').length : (cards ? cards.querySelectorAll('.pp-card').length : -1);
        if (count !== 0) return;
        var host = (tbody && tbody.closest('.ppd-grid-table-wrap')) || cards; if (!host) return;
        var addBtn = document.querySelector('.pp-toolbar [data-pp-btn=add]');
        var cta = addBtn ? '<button type="button" class="ppm-empty-cta">' + '<i class="bi bi-plus-lg"></i> Add the first record</button>' : '';
        var e = el('div', 'ppm-empty', '<i class="bi bi-inbox" aria-hidden="true"></i><h3>Nothing here yet</h3><p>Records you add will show up here.</p>' + cta);
        host.style.display = 'none';
        host.parentNode.insertBefore(e, host.nextSibling);
        if (addBtn) e.querySelector('.ppm-empty-cta').addEventListener('click', function () { haptic(); addBtn.click(); });
    }

    /* ─────────── 12. Native toasts (window.ppmToast) + Undo ─────────── */
    function toastHost() {
        var h = document.querySelector('.ppm-toasts');
        if (!h) { h = el('div', 'ppm-toasts'); h.setAttribute('aria-live', 'polite'); document.body.appendChild(h); }
        return h;
    }
    function ppmToast(msg, opts) {
        if (!CFG.toasts) return null;
        opts = opts || {};
        var t = el('div', 'ppm-toast' + (opts.type ? ' is-' + opts.type : ''));
        t.appendChild(el('span', 'ppm-toast-msg', String(msg == null ? '' : msg)));
        var timer, done = false;
        function close() { if (done) return; done = true; t.classList.remove('show'); setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 260); }
        if (opts.action) {
            var a = el('button', 'ppm-toast-act'); a.type = 'button'; a.textContent = opts.action;
            a.addEventListener('click', function () { haptic(); try { opts.onAction && opts.onAction(); } finally { clearTimeout(timer); close(); } });
            t.appendChild(a);
        }
        toastHost().appendChild(t);
        requestAnimationFrame(function () { t.classList.add('show'); });
        // swipe / tap to dismiss
        var sx = 0, drag = false;
        t.addEventListener('touchstart', function (e) { sx = e.touches[0].clientX; drag = true; }, { passive: true });
        t.addEventListener('touchmove', function (e) { if (!drag) return; var dx = e.touches[0].clientX - sx; t.style.transform = 'translateX(' + dx + 'px)'; t.style.opacity = String(Math.max(0, 1 - Math.abs(dx) / 200)); }, { passive: true });
        t.addEventListener('touchend', function () { drag = false; if (Math.abs(parseFloat(t.style.transform.replace(/[^-\d.]/g, '')) || 0) > 90) close(); else { t.style.transform = ''; t.style.opacity = ''; } }, { passive: true });
        var dur = opts.duration == null ? (opts.action ? 6000 : 3200) : opts.duration;
        if (dur) timer = setTimeout(close, dur);
        return { close: close };
    }
    window.ppmToast = ppmToast;   // available to generated-app scripts

    /* ─────────── Flavour: native date/time inputs (dates = 'native') ─────────── */
    function enhanceDates() {
        if (!phone() || (FLV.dates || 'custom') !== 'native') return;
        setTimeout(function () {   // let flatpickr finish initialising first
            [].forEach.call(document.querySelectorAll('input.flatpickr-input, input[data-pp-date], input[data-pp-time24], input.pp-time24'), function (inp) {
                if (inp.__ppmDate || inp.closest('.pp-nepali, [data-pp-nepali]')) return;   // never touch the BS calendar
                inp.__ppmDate = true;
                var isTime = inp.hasAttribute('data-pp-time24') || inp.classList.contains('pp-time24') || /\btime\b/i.test((inp.name || '') + ' ' + (inp.getAttribute('data-pp-field') || ''));
                try { if (inp._flatpickr) inp._flatpickr.destroy(); } catch (x) {}
                inp.classList.remove('flatpickr-input');
                try { inp.type = isTime ? 'time' : 'date'; } catch (y) {}
            });
        }, 350);
    }

    /* ─────────── Flavour: action-sheet <select> (selects = 'sheet') ─────────── */
    function openSelectSheet(sel) {
        haptic();
        var list = el('div', 'ppm-opts');
        [].forEach.call(sel.options, function (o) {
            var b = el('button', 'ppm-opt' + (o.selected ? ' on' : '')); b.type = 'button';
            b.innerHTML = '<span>' + (o.textContent || '') + '</span>' + (o.selected ? '<i class="bi bi-check-lg" aria-hidden="true"></i>' : '');
            b.addEventListener('click', function () { sel.value = o.value; sel.dispatchEvent(new Event('change', { bubbles: true })); haptic(); sheet.close(); });
            list.appendChild(b);
        });
        var sheet = openSheet({ title: (sel.getAttribute('aria-label') || sel.getAttribute('data-pp-field') || sel.name || 'Choose'), content: list });
    }
    function actionSheetSelects() {
        if (!phone() || (FLV.selects || 'native') !== 'sheet') return;
        [].forEach.call(document.querySelectorAll('select'), function (sel) {
            if (sel.__ppmSel || sel.multiple || sel.closest('.ppm-no-enhance')) return;
            sel.__ppmSel = true;
            var open = function (e) { if (!phone()) return; e.preventDefault(); openSelectSheet(sel); };
            sel.addEventListener('mousedown', open);
            sel.addEventListener('touchstart', open, { passive: false });
            sel.addEventListener('keydown', function (e) { if ((e.key === 'Enter' || e.key === ' ') && phone()) { e.preventDefault(); openSelectSheet(sel); } });
        });
    }

    /* ─────────── Flavour: numeric steppers (numbers = 'stepper') ─────────── */
    function initSteppers() {
        if (!phone() || (FLV.numbers || 'plain') !== 'stepper') return;
        [].forEach.call(document.querySelectorAll('input[type=number], input[inputmode=numeric], input[inputmode=decimal]'), function (inp) {
            if (inp.__ppmStep || inp.closest('.ppm-no-enhance, .ppm-stepper')) return;
            inp.__ppmStep = true;
            var wrap = el('div', 'ppm-stepper');
            inp.parentNode.insertBefore(wrap, inp);
            var minus = el('button', 'ppm-step-btn', '−'); minus.type = 'button'; minus.setAttribute('aria-label', 'Decrease');
            var plus = el('button', 'ppm-step-btn', '+'); plus.type = 'button'; plus.setAttribute('aria-label', 'Increase');
            wrap.appendChild(minus); wrap.appendChild(inp); wrap.appendChild(plus);
            var step = parseFloat(inp.getAttribute('step')) || 1;
            function bump(d) {
                var v = parseFloat(inp.value) || 0; v += d * step;
                if (inp.min !== '' && v < parseFloat(inp.min)) v = parseFloat(inp.min);
                if (inp.max !== '' && v > parseFloat(inp.max)) v = parseFloat(inp.max);
                inp.value = (step % 1 === 0) ? String(Math.round(v)) : v.toFixed(2);
                inp.dispatchEvent(new Event('input', { bubbles: true })); inp.dispatchEvent(new Event('change', { bubbles: true })); haptic();
            }
            minus.addEventListener('click', function () { bump(-1); });
            plus.addEventListener('click', function () { bump(1); });
        });
    }

    /* ═══════════════════════════════════════════════════════════════════════
       PHASE B — signature interactions
       ═══════════════════════════════════════════════════════════════════════ */

    /* ─────────── 13. Pull-to-refresh ─────────── */
    function initPullRefresh() {
        if (!CFG.pullRefresh || window.__ppmPTR) return; window.__ppmPTR = true;
        var THRESH = 74, startY = 0, pulling = false, ring = null, reduce = reduceMotion();
        function scroller() { return document.scrollingElement || document.documentElement; }
        function makeRing() { if (ring) return ring; ring = el('div', 'ppm-ptr', '<i class="bi bi-arrow-clockwise" aria-hidden="true"></i>'); document.body.appendChild(ring); return ring; }
        document.addEventListener('touchstart', function (e) {
            pulling = false;
            if (!phone() || e.touches.length !== 1 || scroller().scrollTop > 0) return;
            if (document.querySelector('.ppm-sheet-open')) return;
            startY = e.touches[0].clientY; pulling = true;
        }, { passive: true });
        document.addEventListener('touchmove', function (e) {
            if (!pulling) return;
            var dist = e.touches[0].clientY - startY;
            if (dist <= 0 || scroller().scrollTop > 0) { pulling = false; if (ring) ring.classList.remove('show'); return; }
            var r = makeRing(); r.classList.add('show');
            var p = Math.min(1, dist / THRESH);
            r.style.transform = 'translateX(-50%) translateY(' + Math.min(dist * 0.5, 58) + 'px)' + (reduce ? '' : ' rotate(' + (p * 300) + 'deg)');
            r.classList.toggle('ready', p >= 1);
            if (e.cancelable && dist > 4) e.preventDefault();
        }, { passive: false });
        document.addEventListener('touchend', function () {
            if (!pulling) return; pulling = false;
            if (ring && ring.classList.contains('ready')) { haptic(12); ring.classList.add('spinning'); location.reload(); }
            else if (ring) { ring.classList.remove('show'); ring.style.transform = ''; }
        }, { passive: true });
    }

    /* ─────────── 14. Reusable bottom-sheet engine (drag-to-dismiss + OS back) ───────────
       window.ppmSheet({title, content, full, onClose}) → { close, body }  */
    function openSheet(opts) {
        opts = opts || {};
        var back = el('div', 'ppm-sheet-back');
        var sheet = el('div', 'ppm-sheet' + (opts.full ? ' ppm-sheet-full' : ''));
        sheet.setAttribute('role', 'dialog'); sheet.setAttribute('aria-modal', 'true');
        var grab = el('div', 'ppm-sheet-grab'); sheet.appendChild(grab);
        var head = el('div', 'ppm-sheet-head');
        head.appendChild(el('div', 'ppm-sheet-title', opts.title || ''));
        var x = el('button', 'ppm-sheet-x', '<i class="bi bi-x-lg" aria-hidden="true"></i>'); x.type = 'button'; x.setAttribute('aria-label', 'Close');
        head.appendChild(x); sheet.appendChild(head);
        var body = el('div', 'ppm-sheet-body'); if (opts.content) body.appendChild(opts.content); sheet.appendChild(body);
        back.appendChild(sheet); document.body.appendChild(back); document.body.classList.add('ppm-sheet-open');
        requestAnimationFrame(function () { back.classList.add('show'); });
        var closed = false, popped = false;
        function teardown() { back.classList.remove('show'); document.body.classList.remove('ppm-sheet-open'); setTimeout(function () { if (back.parentNode) back.remove(); }, 300); if (opts.onClose) opts.onClose(); }
        function close() { if (closed) return; closed = true; teardown(); if (!popped) { try { history.back(); } catch (e) {} } }
        x.addEventListener('click', function () { haptic(); close(); });
        back.addEventListener('click', function (e) { if (e.target === back) close(); });
        // drag the grabber/header down to dismiss
        var sy = 0, drag = false;
        function ds(e) { sy = e.touches[0].clientY; drag = true; sheet.style.transition = 'none'; }
        function dm(e) { if (!drag) return; var dy = e.touches[0].clientY - sy; if (dy < 0) return; sheet.style.transform = 'translateY(' + dy + 'px)'; if (e.cancelable && dy > 4) e.preventDefault(); }
        function de() { if (!drag) return; drag = false; sheet.style.transition = ''; var m = (sheet.style.transform.match(/translateY\(([-\d.]+)px\)/) || [])[1]; if (parseFloat(m || 0) > 120) { haptic(); close(); } else sheet.style.transform = ''; }
        grab.addEventListener('touchstart', ds, { passive: true }); head.addEventListener('touchstart', ds, { passive: true });
        sheet.addEventListener('touchmove', dm, { passive: false }); sheet.addEventListener('touchend', de, { passive: true });
        try { history.pushState({ ppmSheet: true }, ''); } catch (e2) {}
        function onPop() { window.removeEventListener('popstate', onPop); popped = true; close(); }
        window.addEventListener('popstate', onPop);
        return { close: close, body: body };
    }
    window.ppmSheet = openSheet;

    /* ─────────── 15. Modal detail — peek a record in a card that lifts (opt-in, view-only) ─────────── */
    function initModalDetail() {
        if (!CFG.modalDetail || !phone() || window.__ppmMD || !window.fetch || !window.DOMParser) return; window.__ppmMD = true;
        document.addEventListener('click', function (e) {
            var a = e.target.closest('a[href]'); if (!a) return;
            if (a.target === '_blank' || a.hasAttribute('download') || a.hasAttribute('data-no-modal') || e.defaultPrevented) return;
            var href = a.getAttribute('href'); if (!href || href.charAt(0) === '#') return;
            // view pages only (edit pages need their own script init, so let them navigate)
            if (!(/\bview\.php/i.test(href) || a.hasAttribute('data-pp-view'))) return;
            var url; try { url = new URL(a.href, location.href); } catch (x) { return; }
            if (url.origin !== location.origin) return;
            e.preventDefault(); haptic();
            var sh = openSheet({ full: true, title: (a.getAttribute('data-pp-title') || 'Record') });
            sh.body.innerHTML = '<div class="ppm-sheet-load"><span class="ppm-spin"></span></div>';
            fetch(url.href, { credentials: 'same-origin' }).then(function (r) { return r.text(); }).then(function (html) {
                var doc = new DOMParser().parseFromString(html, 'text/html');
                var content = doc.querySelector('.pp-content') || doc.querySelector('.pp-record') || doc.querySelector('.pp-main');
                if (!content) { location.href = url.href; return; }
                sh.body.innerHTML = ''; sh.body.appendChild(document.importNode(content, true));
            }).catch(function () { location.href = url.href; });
        }, true);
    }

    /* ─────────── 16. KPI carousel (dashboard stat tiles → snap carousel) ─────────── */
    function initKpiCarousel() {
        if (!CFG.kpiCarousel || !phone()) return;
        var row = document.querySelector('.ppd-stat-cards'); if (!row || row.__ppmKpi) return;
        var cards = row.children; if (cards.length < 2) return;
        row.__ppmKpi = true; row.classList.add('ppm-kpi-carousel');
        var dots = el('div', 'ppm-kpi-dots');
        for (var i = 0; i < cards.length; i++) { var d = el('span'); if (i === 0) d.className = 'on'; dots.appendChild(d); }
        row.parentNode.insertBefore(dots, row.nextSibling);
        row.addEventListener('scroll', function () {
            var idx = Math.round(row.scrollLeft / Math.max(1, row.clientWidth * 0.82));
            [].forEach.call(dots.children, function (dd, j) { dd.className = j === idx ? 'on' : ''; });
        }, { passive: true });
    }

    /* ─────────── 17. Native page transitions (phone-only; injected so desktop is untouched) ─────────── */
    function initViewTransitions() {
        if (!CFG.viewTransitions || reduceMotion() || !phone() || document.getElementById('ppmVT')) return;
        if (!('startViewTransition' in document)) return;   // supported browsers only
        var s = el('style'); s.id = 'ppmVT';
        s.textContent = '@view-transition{navigation:auto}'
            + '@media(max-width:767.98px){'
            + '::view-transition-old(root){animation:ppmVTout .22s ease both}'
            + '::view-transition-new(root){animation:ppmVTin .24s ease both}'
            + '@keyframes ppmVTout{to{opacity:0;transform:translateX(-16px)}}'
            + '@keyframes ppmVTin{from{opacity:0;transform:translateX(16px)}}}';
        document.head.appendChild(s);
    }

    /* ─────────── 18. Infinite scroll (opt-in; falls back to the pager) ─────────── */
    function nextPagerLink(scope) {
        if (!scope) return null;
        return scope.querySelector('a[rel=next]') ||
            [].filter.call(scope.querySelectorAll('a'), function (a) { return /(next|›|»)/i.test(a.textContent) && !/disabled/.test(a.className); })[0] || null;
    }
    function initInfiniteScroll() {
        if (!CFG.infiniteScroll || !phone() || window.__ppmInf || !window.fetch || !window.IntersectionObserver || !window.DOMParser) return; window.__ppmInf = true;
        var pager = document.querySelector('.pp-pagination'); if (!pager) return;
        var tbody = document.querySelector('.ppd-grid-table tbody'), cards = document.querySelector('.ppd-cards');
        var container = tbody || cards; if (!container) return;
        var link = nextPagerLink(pager); var nextUrl = link ? link.href : null; if (!nextUrl) return;
        var loading = false;
        var sentinel = el('div', 'ppm-inf-sentinel', '<span class="ppm-spin"></span>');
        container.parentNode.insertBefore(sentinel, container.nextSibling);
        pager.style.display = 'none';
        var io = new IntersectionObserver(function (ents) {
            if (!nextUrl || loading || !ents[0].isIntersecting) return;
            loading = true; sentinel.classList.add('load');
            fetch(nextUrl, { credentials: 'same-origin' }).then(function (r) { return r.text(); }).then(function (html) {
                var doc = new DOMParser().parseFromString(html, 'text/html');
                var rows = tbody ? doc.querySelectorAll('.ppd-grid-table tbody tr') : doc.querySelectorAll('.ppd-cards .pp-card');
                [].forEach.call(rows, function (n) { container.appendChild(document.importNode(n, true)); });
                if (tbody) table_recardify(); enhanceMenuLabels(); if (CFG.swipeActions) initSwipeActions();
                var np = doc.querySelector('.pp-pagination'); var nl = nextPagerLink(np);
                nextUrl = nl ? nl.href : null; loading = false; sentinel.classList.remove('load');
                if (!nextUrl) { io.disconnect(); sentinel.remove(); }
            }).catch(function () { loading = false; sentinel.classList.remove('load'); pager.style.display = ''; io.disconnect(); sentinel.remove(); });
        }, { rootMargin: '320px' });
        io.observe(sentinel);
    }
    // re-run cardify on freshly-appended rows (they lack data-label).
    function table_recardify() {
        [].forEach.call(document.querySelectorAll('.ppd-grid-table.ppm-cardified'), function (t) { t.__ppmCardify = false; });
        cardifyTables();
    }

    /* ═══════════════════════════════════════════════════════════════════════
       PHASE C — platform depth / native bridge
       ═══════════════════════════════════════════════════════════════════════ */

    /* ─────────── 19. Add-to-Home-Screen prompt ─────────── */
    function initInstallPrompt() {
        if (!CFG.installPrompt || standalone()) return;
        function dismissed() { try { return Date.now() - (+localStorage.getItem('ppmInstallDismiss') || 0) < 7 * 864e5; } catch (x) { return false; } }
        function banner(html, onAdd) {
            if (!phone() || dismissed() || document.querySelector('.ppm-install')) return null;
            var b = el('div', 'ppm-install', '<i class="bi bi-phone" aria-hidden="true"></i><div class="ppm-install-tx">' + html + '</div>');
            var no = el('button', 'ppm-install-x', '<i class="bi bi-x-lg" aria-hidden="true"></i>'); no.type = 'button'; no.setAttribute('aria-label', 'Dismiss');
            if (onAdd) { var add = el('button', 'ppm-install-add', 'Install'); add.type = 'button'; b.appendChild(add); add.addEventListener('click', function () { haptic(); onAdd(b); }); }
            b.appendChild(no); document.body.appendChild(b);
            requestAnimationFrame(function () { b.classList.add('show'); });
            no.addEventListener('click', function () { try { localStorage.setItem('ppmInstallDismiss', Date.now()); } catch (x) {} b.classList.remove('show'); setTimeout(function () { b.remove(); }, 250); });
            return b;
        }
        var deferred = null;
        window.addEventListener('beforeinstallprompt', function (e) {
            e.preventDefault(); deferred = e;
            banner('<b>Install this app</b><span>Add it to your home screen for the full experience.</span>', function (b) {
                if (!deferred) return; deferred.prompt();
                deferred.userChoice.then(function () { deferred = null; b.classList.remove('show'); setTimeout(function () { b.remove(); }, 250); });
            });
        });
        window.addEventListener('appinstalled', function () { var b = document.querySelector('.ppm-install'); if (b) b.remove(); });
        // iOS has no beforeinstallprompt — show a one-time instruction instead.
        if (/iphone|ipad|ipod/i.test(navigator.userAgent) && !window.MSStream && !standalone()) {
            setTimeout(function () { banner('<b>Install this app</b><span>Tap the Share button, then “Add to Home Screen”.</span>', null); }, 2600);
        }
    }

    /* ─────────── 20. Home-screen app badge ─────────── */
    function initAppBadge() {
        if (!CFG.appBadge || !('setAppBadge' in navigator)) return;
        var n = 0;
        if (typeof CFG.badgeCount === 'number') n = CFG.badgeCount;
        else { var c = document.querySelector('[data-pp-badge-count], .pp-notif-count, .pp-badge-count'); if (c) n = parseInt(((c.getAttribute && c.getAttribute('data-pp-badge-count')) || c.textContent || '').replace(/\D/g, ''), 10) || 0; }
        try { if (n > 0) navigator.setAppBadge(n); else navigator.clearAppBadge(); } catch (x) {}
    }

    /* ─────────── 21. Offline awareness + write queue (opt-in) ─────────── */
    var PPM_Q = 'ppmOfflineQueue';
    function qRead() { try { return JSON.parse(localStorage.getItem(PPM_Q) || '[]'); } catch (x) { return []; } }
    function qWrite(a) { try { localStorage.setItem(PPM_Q, JSON.stringify(a)); } catch (x) {} }
    function initOffline() {
        if (!CFG.offline || window.__ppmOff) return; window.__ppmOff = true;
        var bar = null;
        function setBar(on) {
            if (on && !bar) { bar = el('div', 'ppm-offline-bar', '<i class="bi bi-wifi-off" aria-hidden="true"></i> You’re offline — changes will sync when you reconnect.'); document.body.appendChild(bar); requestAnimationFrame(function () { bar.classList.add('show'); }); }
            else if (!on && bar) { bar.classList.remove('show'); var b = bar; bar = null; setTimeout(function () { b.remove(); }, 250); }
        }
        if (!navigator.onLine) setBar(true);
        window.addEventListener('offline', function () { setBar(true); });
        window.addEventListener('online', function () { setBar(false); flush(); });
        // Capture POST submits while offline (skip file uploads — can't persist those safely).
        document.addEventListener('submit', function (e) {
            var f = e.target; if (!f || f.tagName !== 'FORM') return;
            if ((f.method || 'get').toLowerCase() !== 'post') return;
            if (navigator.onLine) return;
            if (f.querySelector('input[type=file]')) return;   // let the app handle file forms itself
            e.preventDefault();
            var data = {}; new FormData(f).forEach(function (v, k) { if (typeof v === 'string') data[k] = v; });
            var q = qRead(); q.push({ url: f.action || location.href, body: data, at: Date.now() }); qWrite(q);
            ppmToast('Saved offline — will sync when you reconnect', { type: 'info' });
        }, true);
        flush();
        function flush() {
            var q = qRead(); if (!q.length || !navigator.onLine || !window.fetch) return;
            var remaining = [], pending = q.length, ok = 0;
            q.forEach(function (item) {
                var body = new URLSearchParams(item.body);
                fetch(item.url, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body })
                    .then(function (r) { if (r && (r.ok || r.redirected)) ok++; else remaining.push(item); })
                    .catch(function () { remaining.push(item); })
                    .finally(function () { if (--pending === 0) { qWrite(remaining); if (ok) ppmToast('Synced ' + ok + ' change' + (ok > 1 ? 's' : ''), { type: 'success' }); } });
            });
        }
    }

    /* ─────────── 22. Web Push (inert until a VAPID key is configured) ─────────── */
    function b64ToU8(base64) {
        var pad = '='.repeat((4 - base64.length % 4) % 4);
        var b = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
        var raw = atob(b), arr = new Uint8Array(raw.length);
        for (var i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
        return arr;
    }
    function initPush() {
        if (!CFG.push || !CFG.vapidKey || !standalone()) return;
        if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) return;
        if (Notification.permission === 'denied') return;
        if (document.querySelector('.ppm-push-chip')) return;
        var chip = el('button', 'ppm-push-chip', '<i class="bi bi-bell" aria-hidden="true"></i> Enable notifications'); chip.type = 'button';
        document.body.appendChild(chip); requestAnimationFrame(function () { chip.classList.add('show'); });
        chip.addEventListener('click', function () {
            haptic();
            Notification.requestPermission().then(function (p) {
                if (p !== 'granted') { chip.remove(); return; }
                navigator.serviceWorker.ready.then(function (reg) {
                    return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToU8(CFG.vapidKey) });
                }).then(function (sub) {
                    return fetch(appBase() + '/api/push-subscribe.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(sub) });
                }).then(function () { chip.remove(); ppmToast('Notifications enabled', { type: 'success' }); })
                    .catch(function () { chip.remove(); });
            });
        });
    }

    /* ─────────── 24. Autosave form drafts (add forms) ─────────── */
    function initFormDrafts() {
        if (!CFG.formDrafts || !phone()) return;
        [].forEach.call(document.querySelectorAll('form'), function (f) {
            if (f.__ppmDraft || f.querySelector('input[type=password]')) return;   // never persist auth forms
            var fields = f.querySelectorAll('input:not([type=hidden]):not([type=file]):not([type=checkbox]):not([type=radio]), textarea, select');
            if (fields.length < 2) return;                                          // skip search / trivial forms
            f.__ppmDraft = true;
            var key = 'ppmDraft:' + location.pathname;
            var saved = null; try { saved = JSON.parse(localStorage.getItem(key) || 'null'); } catch (x) {}
            if (saved) {
                var filled = 0;
                [].forEach.call(fields, function (elm) { if (elm.name && saved[elm.name] != null && !elm.value) { elm.value = saved[elm.name]; filled++; } });
                if (filled) ppmToast('Draft restored', { action: 'Discard', onAction: function () { try { localStorage.removeItem(key); } catch (x) {} location.reload(); } });
            }
            var t = null;
            f.addEventListener('input', function () {
                if (t) clearTimeout(t);
                t = setTimeout(function () {
                    var data = {}; [].forEach.call(fields, function (elm) { if (elm.name) data[elm.name] = elm.value; });
                    try { localStorage.setItem(key, JSON.stringify(data)); } catch (x) {}
                }, 500);
            });
            f.addEventListener('submit', function () { try { localStorage.removeItem(key); } catch (x) {} });
        });
    }

    /* ─────────── 25. Share-target receiver (shows what was shared in) ─────────── */
    function initShareReceiver() {
        if (!CFG.shareTarget) return;
        var m = /[?&]pp_shared=([^&]+)/.exec(location.search); if (!m) return;
        var txt = ''; try { txt = decodeURIComponent(m[1].replace(/\+/g, ' ')); } catch (x) {}
        if (txt) ppmToast('Shared: ' + txt.slice(0, 60), { type: 'info', duration: 5000 });
    }

    /* ─────────── boot ─────────── */
    function sync() { buildTabbar(); buildNavButtons(); if (CFG.swipeActions) initSwipeActions(); cardifyTables(); enhanceMenuLabels(); enhanceInputs(); enhanceDates(); actionSheetSelects(); initSteppers(); initLiveSearch(); initEmptyStates(); }
    function boot() {
        initColorScheme();
        sync();
        initEdgeSwipe();
        initSkeleton();
        initPWA();
        // Phase B
        initPullRefresh();
        initViewTransitions();
        initKpiCarousel();
        initModalDetail();
        initInfiniteScroll();
        // Phase C
        initInstallPrompt();
        initAppBadge();
        initOffline();
        initPush();
        initFormDrafts();
        initShareReceiver();
        // Navigating to a NEW page truncates forward history → clear the fallback flag.
        document.addEventListener('click', function (e) {
            var a = e.target.closest('a[href]'); if (!a || a.classList.contains('ppm-navedge')) return;
            var href = a.getAttribute('href'); if (!href || href.charAt(0) === '#') return;
            try { sessionStorage.removeItem('ppmFwd'); } catch (x) {}
        }, true);
        window.addEventListener('pageshow', refreshNavButtons);
        window.addEventListener('popstate', refreshNavButtons);
        var t = null;
        window.addEventListener('resize', function () { if (t) return; t = setTimeout(function () { t = null; sync(); initKpiCarousel(); }, 150); });
    }
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
    else boot();
})();
