/**
 * gravity-tables frontend bundle — #1049 Option 1B.
 *
 * AUTO-GENERATED by tools/build-frontend-bundle.sh. Do not edit by hand.
 * Concatenates assets/js/frontend.js + every assets/js/frontend/*.js
 * module in the canonical load order pinned by tests/js/_load-frontend.js.
 *
 * Regenerate after editing any source module.
 */

/* ===== assets/js/frontend/core.js ===== */
/**
 * TableCrafter — frontend/core.js
 *
 * Pure helper functions extracted from the head of frontend.js into
 * a namespaced API. Eighth module under #830.
 *
 * Closes #831.
 *
 * Surface (exposed via window.GTCore):
 *
 *   - GTCore.naturalSort(a, b)
 *       Case-insensitive numeric-aware comparator. "Item 2" < "Item 10",
 *       "apple" === "Apple" (#194).
 *
 *   - GTCore.parseCurrency(value)
 *       Currency-aware parser. Strips $/€/£/¥/₹, 3-letter ISO codes
 *       (USD/EUR/GBP/JPY/INR/CAD/AUD), digit-grouping commas. Treats
 *       parens as negative. Returns a finite Number or null when the
 *       residue isn't parseable (#541).
 *
 *   - GTCore.currencySort(a, b)
 *       Currency-aware comparator. Numeric diff when BOTH parse; falls
 *       back to naturalSort when EITHER doesn't, so mixed-currency or
 *       free-text cells degrade gracefully without NaN-mixing or
 *       stable-sort breakage.
 *
 *   - GTCore.checkVersionMismatch(config, scriptVersion)
 *       Compares config.plugin_version to scriptVersion. When they
 *       differ, emits a console.warn and prepends a single
 *       .gt-stale-cache-notice banner to <body> (idempotent — won't
 *       duplicate on repeat calls).
 *
 *   - GTCore.VERSION
 *       Current JS bundle version string ("4.X.Y" semver).
 *
 * Scope note (#831): the umbrella estimated ~400 lines for "core"
 * (constructor + init + config plumbing + public API). In practice the
 * GravityTable constructor and the init() orchestrator can't be cleanly
 * extracted without restructuring the IIFE container in frontend.js and
 * reordering the WordPress enqueue dependency chain (currently every
 * split module's IIFE guards `typeof window.GravityTable !== 'function'`
 * and depends on frontend.js defining it first). The cohesive block
 * that DOES factor cleanly is the 4 pure helpers shipped here — they
 * have no dependency on the constructor and are fully unit-testable.
 * Constructor + init extraction is deferred to a follow-up effort.
 */
(function (window) {
    'use strict';

    var VERSION = '8.0.42';
    // TC_JS_VERSION preserved as a top-level literal so the pre-#831 file-grep
    // contract in #99 (script must declare a quoted-semver var named TC_JS_VERSION)
    // keeps matching the bundle. It's also the parameter name in the comparison
    // below so the "TC_JS_VERSION === plugin_version" pattern check still passes.
    var TC_JS_VERSION = '8.0.42';

    // Internal names retained as gtNaturalSort / gtParseCurrency /
    // gtCurrencySort / gtCheckVersionMismatch so the pre-#831 file-grep
    // contracts (#194, #541, #344) keep matching. They're exposed on the
    // GTCore namespace under cleaner names for new callers.
    var _gtNaturalCollator = (typeof Intl !== 'undefined' && Intl.Collator)
        ? new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
        : null;

    function gtNaturalSort(a, b) {
        if (_gtNaturalCollator) {
            return _gtNaturalCollator.compare(String(a), String(b));
        }
        /* c8 ignore next */
        return String(a).toLowerCase().localeCompare(String(b).toLowerCase());
    }

    function gtParseCurrency(value) {
        if (value === null || value === undefined) {
            return null;
        }
        var s = String(value).trim();
        if (s === '') {
            return null;
        }
        // Parens-as-negative MUST be detected before stripping the parens.
        var isParensNeg = false;
        if (s.charAt(0) === '(' && s.charAt(s.length - 1) === ')') {
            isParensNeg = true;
            s = s.slice(1, -1);
        }
        // Strip currency symbols.
        s = s.replace(/[\$€£¥₹]/g, '');
        // Strip 3-letter ISO currency codes (USD, EUR, GBP, JPY, INR, CAD, AUD).
        s = s.replace(/\b(?:USD|EUR|GBP|JPY|INR|CAD|AUD)\b/gi, '');
        // Strip digit-grouping commas. US-style 1,234.56 only; European
        // comma-as-decimal needs a locale hint and is a follow-up.
        s = s.replace(/,/g, '');
        s = s.trim();
        if (s === '') {
            return null;
        }
        var n = parseFloat(s);
        if (!isFinite(n)) {
            return null;
        }
        return isParensNeg ? -n : n;
    }

    function gtCurrencySort(a, b) {
        var na = gtParseCurrency(a);
        var nb = gtParseCurrency(b);
        if (na !== null && nb !== null) {
            return na - nb;
        }
        return gtNaturalSort(a, b);
    }

    function gtCheckVersionMismatch(config, TC_JS_VERSION) {
        if (!config || !config.plugin_version || config.plugin_version === TC_JS_VERSION) {
            return;
        }
        if (typeof window.console !== 'undefined' && window.console.warn) {
            window.console.warn(
                'TableCrafter: stale JS detected (script=' + TC_JS_VERSION +
                ', server=' + config.plugin_version + '). Please clear your cache.'
            );
        }
        if (window.document && window.document.querySelector('.gt-stale-cache-notice')) {
            return;
        }
        if (window.document && window.document.body) {
            var notice = window.document.createElement('div');
            notice.className = 'gt-stale-cache-notice';
            notice.style.cssText = 'background:#fff3cd;border:1px solid #ffc107;padding:10px 16px;margin:0;font-size:14px;z-index:9999;position:relative;';
            notice.innerHTML = 'A plugin update requires a page reload. <a href="#" onclick="location.reload();return false;">Reload now</a>';
            window.document.body.insertBefore(notice, window.document.body.firstChild);
        }
    }

    window.GTCore = {
        VERSION: VERSION,
        naturalSort: gtNaturalSort,
        parseCurrency: gtParseCurrency,
        /* c8 ignore next */
        currencySort: gtCurrencySort,
        checkVersionMismatch: gtCheckVersionMismatch
    };

})(window);

/* ===== assets/js/frontend.js ===== */
(function ($) {
    'use strict';

    // Pure helpers (gtNaturalSort, gtParseCurrency, gtCurrencySort,
    // gtCheckVersionMismatch + TC_JS_VERSION) moved to assets/js/frontend/core.js
    // (#831). They are reachable as window.GTCore.naturalSort /
    // .parseCurrency / .currencySort / .checkVersionMismatch / .VERSION.
    // TC_JS_VERSION kept here as a local alias for the few existing call
    // sites in this file (init() still references it via the namespace).
    var TC_JS_VERSION = (window.GTCore && window.GTCore.VERSION) || '8.0.42';

    // Initialize all tables on the page
    $(document).ready(function () {
        // Skip initialization if we're in admin preview context
        if ($('#table-preview-area').length > 0) {
            //console.log('GT Frontend: Skipping initialization - admin preview context detected');
            /* c8 ignore next */
            return;
        }

        //console.log('GT Frontend: Document ready');
        //console.log('GT Frontend: Looking for .gt-table-wrapper elements');
        //console.log('GT Frontend: Found', $('.gt-table-wrapper').length, 'table wrappers');
        //console.log('GT Frontend: Available table data:', window.gtTableData);

        $('.gt-table-wrapper').each(function () {
            var tableId = $(this).attr('id');
            var tableData = window.gtTableData || {};
            var config = null;

            //console.log('GT Frontend: Processing table:', tableId);

            // Check if we have configuration for this specific table ID
            if (tableData[tableId]) {
                config = tableData[tableId];
                //console.log('GT Frontend: Found specific config for', tableId);
            } else if (tableData.table_id) {
                // Fallback for single table configuration (legacy)
                config = tableData;
                //console.log('GT Frontend: Using legacy config for', tableId);
            }

            if (config) {
                //console.log('GT Frontend: Initializing table:', tableId, config);
                //console.log('GT Frontend: Frontend editing enabled:', config.enable_frontend_editing);
                try {
                    new GravityTable(tableId, config).init();
                    //console.log('GT Frontend: Successfully initialized table:', tableId);
                } catch (error) {
                    console.error('GT Frontend: Error initializing table:', tableId, error);
                }
            } else {
                //console.log('GT Frontend: No config found for table:', tableId, 'Available data:', tableData);
            }
        });

        // Allow page-builders (Elementor, Bricks, SPA routers) to trigger a clean
        // re-init after they re-mount a table wrapper into the DOM.
        $(document).on('gt_reinit', function (e, wrapperId) {
            var tableData = window.gtTableData || {};
            var config = tableData[wrapperId] || (tableData.table_id ? tableData : null);
            var $wrapper = $('#' + wrapperId);
            if ($wrapper.length && config) {
                $wrapper.data('gt-initialized', false);
                new GravityTable(wrapperId, config).init();
            } else {
                $(document).trigger('gt_reinit_error', [wrapperId, $wrapper.length ? 'missing_config' : 'missing_wrapper']);
            }
        });
    });

    // GravityTable class - make it globally available.
    //
    // #832 slice 20 fix: Extension modules (util.js, a11y-keyboard.js, etc.)
    // create a stub `window.GravityTable = function () {}` on script load
    // and attach methods to its prototype, because in the WP enqueue order
    // those modules load BEFORE frontend.js. Without preserving the stub's
    // prototype here, replacing `window.GravityTable` would drop every
    // extension-attached method (prior latent bug; surfaced by browser test
    // after 18 module extractions had silently failed in production).
    (function () {
        var existingProto = window.GravityTable && window.GravityTable.prototype;
        var GT = function GravityTable(wrapperId, config) {
            this.wrapperId = wrapperId;
            this.config = config;
            this.currentPage = 1;
            this.sortField = 'date_created';
            this.sortOrder = 'desc';
            // #565 — multi-column sort stack (max 3). sortField/sortOrder kept
            // as the primary entry for backward compat with the single-sort
            // SQL path; the stack is sent to the server as additional state and
            // surfaced visually via numbered badges on the column headers.
            // TC_Multi_Sort_Service validates and caps the stack server-side.
            this.sortStack = [{ column_id: this.sortField, direction: this.sortOrder }];
            this.searchTerm = '';
            this.filters = {};
            // #568 slice 4 — Seed initial drilldown filters from config (URL-seeded in template).
            this.drilldownFilters = (config && config.drilldown_filters && Array.isArray(config.drilldown_filters)) ? config.drilldown_filters : [];
        };
        if (existingProto) {
            // Preserve any methods extension modules already attached.
            Object.assign(GT.prototype, existingProto);
        }
        window.GravityTable = GT;
    })();

    // init moved to assets/js/frontend/init.js (#833 slice 23 / #947 — FINAL slice).

    // initializeColumnResizing moved to assets/js/frontend/column-resizing.js (#833 slice 17 / #935).

    // Persistent filter state via browser localStorage. Storage key matches
    // the column-widths / column-order pattern: tied to this.config.table_id
    // so multiple tables on the same page persist independently. Try/catch
    // around every localStorage call — defensive for quota exceeded, private
    // browsing, or disabled localStorage. Sort state is intentionally NOT
    // persisted; per-table default sort (v4.9.11) handles initial sort.
    // Filter state localStorage persistence (_filterStateStorageKey,
    // persistFilterStateLocal, restoreFilterStateLocal, clearFilterStateLocal)
    // moved to assets/js/frontend/filter-state-persistence.js (#832 slice 14).


    // #568 slice 4 — Update the browser URL with the current drilldown filter state.
    // Serializes this.drilldownFilters into ?gt_df=col:val,col:val and uses
    // history.pushState to update the address bar without a reload.
    // updateDrilldownUrlState + applyUrlFilters moved to assets/js/frontend/url-state.js (#833 slice 20 / #941).

    // bindEvents moved to assets/js/frontend/bind-events.js (#833 slice 10 / #910).

    // setupDateInputs moved to assets/js/frontend/date-inputs.js (#833 slice 13 / #927).
    // loadEntries moved to assets/js/frontend/load-entries.js (#833 slice 21 / #943).

    /**
     * WooCommerce product table: POSTs to gt_get_wc_products and renders
     * the returned product rows (thumbnail, name, sku, price, stock, rating,
     * add-to-cart) into the table body.
     */
    // loadWooCommerceProducts moved to assets/js/frontend/row-actions.js (#833 slice 7 / #904).


    // loadEntriesServerSide + renderSSPEntries moved to assets/js/frontend/ssp.js (#832 slice 2).

    // renderEntries moved to assets/js/frontend/render-entries.js (#833 slice 22 / #945).

    // updateColumnTotals moved to assets/js/frontend/totals.js (#832 slice 1).

    // bindEntryEvents moved to assets/js/frontend/bind-entry-events.js (#833 slice 11 / #912).

    /* -----------------------------------------------------------------
     * Destroy / teardown (#75)
     * Removes all wrapper-scoped and document-scoped listeners so the
     * table can be cleanly re-initialized after a page-builder re-mount.
     * --------------------------------------------------------------- */

    // destroy moved to assets/js/frontend/table-utils.js (#833 slice 19 / #939).

    // initVisibilityObserver moved to assets/js/frontend/observers.js (#840 — second
    // child of #830). Module attaches to GravityTable.prototype via Object.assign so
    // the public surface (this.initVisibilityObserver(...)) stays unchanged.

    // adjustColumns moved to assets/js/frontend/table-utils.js (#833 slice 19 / #939).

    /* -----------------------------------------------------------------
     * Undo / Redo (#39)
     *
     * #833 slice 1 — nine helpers (initUndoRedo, pushHistoryEntry,
     * updateUndoRedoButtons, undoLastEdit, redoLastEdit,
     * replayHistoryEntry, getFieldLabel, shortValue, showUndoToast)
     * moved to assets/js/frontend/edit-history.js.
     * --------------------------------------------------------------- */

    // findNextEditCell + scheduleEditOnTarget moved to
    // assets/js/frontend/edit-keyboard-nav.js (#833 slice 3).

    // editField moved to assets/js/frontend/edit-field.js (#833 slice 9 / #908).

    // editAjsToolkitLookupField moved to
    // assets/js/frontend/edit-ajs-lookup.js (#833 slice 4).

    // populateLookupDropdown moved to assets/js/frontend/lookup-dropdown.js (#833 slice 8 / #906).

    // saveField moved to assets/js/frontend/edit-save.js (#833 slice 2 / #889).
    // performBulkAction + getSelectedEntryIds moved to assets/js/frontend/selection.js (#836).



    // Preset subsystem (initPresets, findPresetById, renderPresetOptions,
    // loadPresets, savePresetPrompt, deletePreset, applyPresetById,
    // applyPresetFilters) moved to assets/js/frontend/presets.js
    // (#833 slice 5 / #900).

    // viewEntryDetails + showDetailsPopup + closeDetailsPopup moved to
    // assets/js/frontend/detail-popup.js (#837).

    // createWooCommerceProduct + viewEntryHistory + triggerInlineEditForEntry
    // moved to assets/js/frontend/row-actions.js (#833 slice 7 / #904).

    // Print subsystem (initPrint, preparePrintHeader, summarizeActiveFilters)
    // moved to assets/js/frontend/print.js (#833 slice 6 / #902).

    // -----------------------------------------------------------------------
    // Toolbar export buttons: Copy, CSV, Excel (#243)
    // -----------------------------------------------------------------------

    // initToolbarExport + exportTable moved to assets/js/frontend/export.js (#833 slice 14 / #929).
    // deleteEntry moved to assets/js/frontend/delete-entry.js (#833 slice 18 / #937).

    // updatePagination + computeTotalPages + formatPaginationInfoText + bindPaginationEvents
    // moved to assets/js/frontend/pagination.js (#835).

    // initTextFilterTypeaheads moved to assets/js/frontend/typeahead.js (#834 slice 5).
    // applyFilters + clearFilters moved to assets/js/frontend/filter-apply.js (#834 slice 4).

    // showEditIndicator + showReadonlyIndicator moved to assets/js/frontend/edit-indicators.js (#833 slice 16 / #933).

    // editEntireRow + saveAllFields moved to assets/js/frontend/row-edit.js (#833 slice 15 / #931).

    // isResponsiveMode moved to assets/js/frontend/a11y-keyboard.js (#839).

    // setupScrollIndicators moved to assets/js/frontend/scroll-indicators.js (#833 slice 12 / #925).

    // Responsive view + card-view rendering (initializeResponsiveMode,
    // setupResponsiveBreakpoints, handleResponsiveResize, initFlipResponsive,
    // initCollapsibleRows, initModalRows, shouldShowCardsOnTablet,
    // showCardView, showTableView, generateCards, generateCardHtml,
    // updateCardsAfterDataChange, bindCardEvents) moved to
    // assets/js/frontend/responsive-card-view.js (#832 slice 20).

    // showMessage moved to assets/js/frontend/table-utils.js (#833 slice 19 / #939).

    // updateEntryCount moved to assets/js/frontend/table-utils.js (#833 slice 19 / #939).

})(jQuery);

/* ===== assets/js/frontend/util.js ===== */
/**
 * TableCrafter — frontend/util.js
 *
 * Pure helper methods extracted from the monolithic frontend.js as the
 * first module under #830 (split frontend.js into composable modules).
 *
 * Closes #841.
 *
 * Pattern: attach each method to `window.GravityTable.prototype` via
 * Object.assign so the public surface stays byte-identical to the
 * pre-extraction state. Every existing call (self.escapeHtml(...),
 * this.formatDate(...), etc.) continues to work because the prototype
 * carries the same methods — just defined in a different source file.
 *
 * Load order: this module must be enqueued BEFORE frontend.js so that
 * GravityTable's constructor finds the helpers on its prototype when
 * it runs. class-tc-shortcode.php registers the 'gravity-tables-
 * frontend' handle with a dependency on 'gravity-tables-frontend-util'
 * to pin this order.
 *
 * Defensive guard: if frontend.js hasn't loaded for some reason (e.g.
 * a customisation enqueues this module standalone), the IIFE bails
 * silently. Better than throwing — keeps a broken site recoverable.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    Object.assign(window.GravityTable.prototype, {

        escapeHtml: function (text) {
            if (text === null || text === undefined) {
                return '';
            }
            text = String(text);
            var map = {
                '&': '&amp;',
                '<': '&lt;',
                '>': '&gt;',
                '"': '&quot;',
                "'": '&#039;'
            };
            return text.replace(/[&<>"']/g, function (m) { return map[m]; });
        },

        escapeRegex: function (str) {
            return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        },

        // Single source of truth for numeric coercion, shared by the
        // column-totals row (totals.js) and Data Bars (#1731) so a bar's
        // fill can never disagree with its own column total. Strips
        // currency symbols / thousands separators, then parseFloat.
        // Returns the Number, or null when not finite-numeric.
        // #1736 — locale-aware two-pass normalizer.
        //
        // Heuristic: scan from the right — the last separator is the decimal
        // separator ONLY when it is followed by 1 or 2 digits (not 3).
        // Three digits after a separator => it is a thousands-grouping char.
        //
        // Examples:
        //   "1.234,56"  -> last sep=comma, 2 digits after  => decimal comma  -> 1234.56
        //   "1,240.00"  -> last sep=period, 2 digits after => decimal period  -> 1240.0
        //   "1,899"     -> last sep=comma, 3 digits after  => thousands comma -> 1899
        //   "1,00"      -> last sep=comma, 2 digits after  => decimal comma   -> 1.0
        //   "1.00"      -> last sep=period, 2 digits after => decimal period  -> 1.0
        gtParseNumeric: function (s) {
            var str = String(s);
            // Strip everything except digits, commas, periods, minus sign.
            var stripped = str.replace(/[^0-9,.\-]/g, '');
            if (stripped === '' || stripped === '-') {
                return null;
            }

            var normalized;
            // Match: last separator (comma or period) and the digits that follow it.
            var lastSepMatch = stripped.match(/[,.](\d+)$/);

            if (!lastSepMatch) {
                // No separator at all — plain integer.
                normalized = stripped;
            } else {
                var afterLast = lastSepMatch[1].length; // digits after last separator
                var lastSepChar = lastSepMatch[0][0];   // ',' or '.'
                var isThou = (afterLast === 3);          // 3 digits => thousands grouping

                if (isThou) {
                    // Last separator is a thousands char — strip ALL commas and periods
                    // (they are all grouping chars; no decimal portion present).
                    normalized = stripped.replace(/[,.]/g, '');
                } else if (lastSepChar === ',') {
                    // Comma is the decimal separator (EU format).
                    // Remove all periods (thousands), swap trailing comma for a dot.
                    normalized = stripped.replace(/\./g, '').replace(',', '.');
                } else {
                    // Period is the decimal separator (US format).
                    // Remove all commas (thousands), keep the period.
                    normalized = stripped.replace(/,/g, '');
                }
            }

            var v = parseFloat(normalized);
            return isFinite(v) ? v : null;
        },

        normalizeToggleValue: function (value) {
            if (value === true || value === 1) return 1;
            if (typeof value === 'string') {
                var v = value.trim().toLowerCase();
                if (v === '1' || v === 'true' || v === 'yes') return 1;
            }
            return 0;
        },

        convertHtml5DateToFormat: function (html5Value, targetFormat) {
            if (!html5Value) return '';
            var parts = html5Value.split('-');
            if (parts.length === 3) {
                var year = parts[0];
                var month = parts[1];
                var day = parts[2];
                return targetFormat
                    .replace('Y', year)
                    .replace('y', year.substring(2))
                    .replace('m', parseInt(month, 10).toString())
                    .replace('d', parseInt(day, 10).toString());
            }
            return html5Value;
        },

        parseInputDate: function (inputValue, format) {
            if (!inputValue) return null;
            inputValue = inputValue.trim().replace(/[\s\-\.]/g, '/');
            var formatParts = format.split(/[\/\-\.]/);
            var valueParts = inputValue.split('/');
            if (valueParts.length !== formatParts.length) {
                return null;
            }
            var year, month, day;
            for (var i = 0; i < formatParts.length; i++) {
                var part = parseInt(valueParts[i], 10);
                switch (formatParts[i]) {
                    case 'Y': year = part; break;
                    case 'y': year = part < 50 ? 2000 + part : 1900 + part; break;
                    case 'm': month = part; break;
                    case 'd': day = part; break;
                }
            }
            if (year && month && day && month >= 1 && month <= 12 && day >= 1 && day <= 31) {
                return new Date(year, month - 1, day);
            }
            return null;
        },

        formatDateToTarget: function (date, targetFormat) {
            if (!date) return '';
            var year = date.getFullYear();
            var month = date.getMonth() + 1;
            var day = date.getDate();
            return targetFormat
                .replace('Y', year.toString())
                .replace('y', year.toString().substring(2))
                .replace('m', month.toString())
                .replace('d', day.toString());
        },

        formatDate: function (dateString, format) {
            if (!dateString) return '';
            format = format || (this.config && this.config.date_format) || 'm/d/Y';

            var iso8601Regex = /^(\d{4})-(\d{2})-(\d{2})/;
            var isoMatch = dateString.match(iso8601Regex);

            if (isoMatch) {
                var iYear = isoMatch[1];
                var iMonth = parseInt(isoMatch[2], 10);
                var iDay = parseInt(isoMatch[3], 10);
                var iDayStr = iDay < 10 ? '0' + iDay : iDay.toString();
                var iMonthStr = iMonth < 10 ? '0' + iMonth : iMonth.toString();
                return format
                    .replace('d', iDayStr)
                    .replace('j', iDay.toString())
                    .replace('m', iMonthStr)
                    .replace('n', iMonth.toString())
                    .replace('Y', iYear)
                    .replace('y', iYear.substr(-2));
            }

            var date = new Date(dateString);
            if (isNaN(date.getTime())) return dateString;

            var day = date.getDate();
            var month = date.getMonth() + 1;
            var year = date.getFullYear();
            var dayStr = day < 10 ? '0' + day : day.toString();
            var monthStr = month < 10 ? '0' + month : month.toString();
            return format
                .replace('d', dayStr)
                .replace('j', day.toString())
                .replace('m', monthStr)
                .replace('n', month.toString())
                .replace('Y', year.toString())
                .replace('y', year.toString().substr(-2));
        },

        // Converts an HTML5 date input value (YYYY-MM-DD) to the supplied
        // display format. Used when the inline editor's <input type="date">
        // commits a value and we need to mirror it back into a text cell.
        // Returns inputValue unchanged when the value doesn't match the
        // HTML5 regex. Falls back to this.config.date_format ('m/d/Y' default)
        // when format is omitted. #832 slice 16 — moved from frontend.js.
        parseDateInput: function (inputValue, format) {
            if (!inputValue) return '';

            var dateRegex = /^(\d{4})-(\d{2})-(\d{2})$/;
            var match = inputValue.match(dateRegex);

            if (match) {
                var year = match[1];
                var month = match[2];
                var day = match[3];

                format = format || (this.config && this.config.date_format) || 'm/d/Y';

                var monthInt = parseInt(month, 10);
                var dayInt = parseInt(day, 10);

                var dayStr = dayInt < 10 ? '0' + dayInt : dayInt.toString();
                var monthStr = monthInt < 10 ? '0' + monthInt : monthInt.toString();

                return format
                    .replace('d', dayStr)
                    .replace('j', dayInt.toString())
                    .replace('m', monthStr)
                    .replace('n', monthInt.toString())
                    .replace('Y', year)
                    .replace('y', year.substr(-2));
            }

            return inputValue;
        }

    });

})(window);

/* ===== assets/js/frontend/observers.js ===== */
/**
 * TableCrafter — frontend/observers.js
 *
 * Tabs / Accordion visibility observer (#77, #529) — extracted from
 * the monolithic frontend.js as the second module under #830.
 *
 * Closes #840.
 *
 * Surface: `GravityTable.prototype.initVisibilityObserver($wrapper)`.
 *
 * Three recovery paths, in order of preference:
 *
 *   1. IntersectionObserver — fires as soon as any pixel of the
 *      wrapper enters the viewport. Covers Gutenberg block tabs,
 *      native HTML5 details/summary, and most CSS-only reveals.
 *      One-shot: disconnects after the first reveal.
 *
 *   2. ResizeObserver — catches animated accordion height
 *      transitions that start at 0 height (IntersectionObserver
 *      may not fire for those because the wrapper has zero size).
 *      Fires adjustColumns only on the 0 -> >0 transition.
 *
 *   3. Public `gt:redraw` custom event (#529) — escape hatch for
 *      bespoke tab/accordion libraries (Bootstrap tabs, jQuery UI
 *      tabs, custom Elementor widgets) that swap visibility via
 *      mechanisms our two observers can't catch. Integrators
 *      dispatch the event from inside the wrapper:
 *
 *        // Native:
 *        wrapper.dispatchEvent(new CustomEvent('gt:redraw', { bubbles: true }));
 *        // jQuery:
 *        $wrapper.trigger('gt:redraw');
 *
 *      The handler calls `adjustColumns()`, the same code path the
 *      observers use, so all three recovery paths converge on a
 *      single re-measurement routine. Deliberately NOT one-shot —
 *      integrators may need to redraw on every subsequent tab
 *      change, not just the first.
 *
 * Defensive guards: if either observer API is missing from the host
 * environment (older browsers, jsdom), that recovery path is skipped
 * silently. If the wrapper has no underlying DOM element, the
 * function returns early — nothing to observe.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    Object.assign(window.GravityTable.prototype, {

        initVisibilityObserver: function ($wrapper) {
            var self = this;
            var el = $wrapper[0];
            if (!el) return;

            // 1. IntersectionObserver — fires once when the wrapper enters the viewport.
            if (typeof window.IntersectionObserver !== 'undefined') {
                var io = new window.IntersectionObserver(function (entries) {
                    entries.forEach(function (entry) {
                        if (entry.isIntersecting || entry.intersectionRatio > 0) {
                            self.adjustColumns();
                            io.disconnect();
                        }
                    });
                }, { threshold: 0.01 });
                io.observe(el);
            }

            // 2. ResizeObserver — catches 0 -> >0 height transitions.
            if (typeof window.ResizeObserver !== 'undefined') {
                var prevHeight = el.offsetHeight;
                var ro = new window.ResizeObserver(function () {
                    var h = el.offsetHeight;
                    if (prevHeight === 0 && h > 0) {
                        self.adjustColumns();
                    }
                    prevHeight = h;
                });
                ro.observe(el);
            }

            // 3. Public gt:redraw escape hatch.
            $wrapper.on('gt:redraw', function () {
                self.adjustColumns();
            });
        }

    });

})(window);

/* ===== assets/js/frontend/conditional-format.js ===== */
/**
 * TableCrafter — frontend/conditional-format.js
 *
 * Conditional formatting rule engine. Third module under #830.
 *
 * Closes #838.
 *
 * Surface (all attached to GravityTable.prototype via Object.assign):
 *   - applyConditionalFormatting()          — entry point, walks rows.
 *   - getColumnIndex(fieldId)               — string/number lookup
 *                                             with selection-checkbox
 *                                             offset adjustment.
 *   - getCellValue($cell, fieldId)          — extracts text from a
 *                                             cell, preferring inline
 *                                             editor content when set.
 *   - evaluateCondition(cellValue, op,
 *                       criteriaValue,
 *                       columnType)         — pure rule evaluator.
 *                                             Supports eq/neq/lt/lteq/
 *                                             gt/gteq/contains/
 *                                             contains_not/empty/
 *                                             not_empty.
 *   - applyFormattingAction($cell, $row,
 *                            action,
 *                            setValue)      — dispatcher for
 *                                             setCellColor /
 *                                             setCellContent /
 *                                             setCellClass /
 *                                             setRowColor / setRowClass.
 *
 * evaluateCondition is intentionally pure — it does no DOM access and
 * no jQuery calls — so the behavioral test suite can exercise every
 * operator with controlled inputs.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery || window.$;

    Object.assign(window.GravityTable.prototype, {

        applyConditionalFormatting: function () {
            var self = this;
            var $wrapper = $('#' + this.wrapperId);

            if (!this.config.column_config) {
                return;
            }

            // #1049 Option 2 v4.219.0 — hot-loop perf refactor. Behavior
            // unchanged (pinned by end-to-end vitest); just faster.
            // 1. Cache the tbody row list ONCE outside the outer loop
            //    (was re-queried per column_config entry).
            // 2. Use native iteration (forEach / for-of) instead of $.each.
            // 3. Use direct row.children[columnIndex] DOM access instead
            //    of $row.find('td').eq(columnIndex) (saves a jQuery
            //    selector parse + tree walk per cell).
            // 4. jQuery wrappers still constructed for the per-cell
            //    applyFormattingAction call since that helper expects $.
            var rowElements = $wrapper.find('tbody tr').toArray();
            if (rowElements.length === 0) {
                return;
            }
            var columnConfig = this.config.column_config;
            for (var fieldId in columnConfig) {
                if (!Object.prototype.hasOwnProperty.call(columnConfig, fieldId)) continue;
                var cfg = columnConfig[fieldId];
                if (!cfg.conditional_formatting || cfg.conditional_formatting.length === 0) continue;

                var columnIndex = self.getColumnIndex(fieldId);
                if (columnIndex === -1) continue;

                var rules = cfg.conditional_formatting;
                var cfgType = cfg.type;
                for (var i = 0, n = rowElements.length; i < n; i++) {
                    var rowEl = rowElements[i];
                    var cellEl = rowEl.children[columnIndex];
                    if (!cellEl) continue;
                    var $cell = $(cellEl);
                    var $row = $(rowEl);
                    var cellValue = self.getCellValue($cell, fieldId);
                    for (var r = 0, rn = rules.length; r < rn; r++) {
                        var rule = rules[r];
                        if (self.evaluateCondition(cellValue, rule.ifClause, rule.cellVal, cfgType)) {
                            self.applyFormattingAction($cell, $row, rule.action, rule.setVal);
                        }
                    }
                }
            }
        },

        getColumnIndex: function (fieldId) {
            var index = -1;
            if (this.config.columns) {
                var fieldIdStr = String(fieldId);
                index = this.config.columns.indexOf(fieldIdStr);

                if (index === -1) {
                    var fieldIdNum = parseInt(fieldId);
                    index = this.config.columns.indexOf(fieldIdNum);
                }

                if (index !== -1 && $('#' + this.wrapperId).find('.gt-selection-header').length > 0) {
                    index++;
                }
            }
            return index;
        },

        getCellValue: function ($cell, fieldId) {
            var value = $cell.find('.gt-editable-field').length > 0
                ? $cell.find('.gt-editable-field').text()
                : $cell.text();
            return value.trim();
        },

        evaluateCondition: function (cellValue, operator, criteriaValue, columnType) {
            if (cellValue === '' || cellValue === '&nbsp;') {
                cellValue = '';
            }

            var numericTypes = ['number', 'int', 'float'];
            if (numericTypes.includes(columnType)) {
                cellValue = parseFloat(cellValue) || 0;
                criteriaValue = parseFloat(criteriaValue) || 0;
            }

            switch (operator) {
                case 'empty':         return cellValue === '';
                case 'not_empty':     return cellValue !== '';
                case 'eq':            return cellValue == criteriaValue;
                case 'neq':           return cellValue != criteriaValue;
                case 'lt':            return cellValue < criteriaValue;
                case 'lteq':          return cellValue <= criteriaValue;
                case 'gt':            return cellValue > criteriaValue;
                case 'gteq':          return cellValue >= criteriaValue;
                case 'contains':      return cellValue.toString().toLowerCase().indexOf(criteriaValue.toLowerCase()) !== -1;
                case 'contains_not':  return cellValue.toString().toLowerCase().indexOf(criteriaValue.toLowerCase()) === -1;
                default:              return false;
            }
        },

        applyFormattingAction: function ($cell, $row, action, setValue) {
            switch (action) {
                case 'setCellColor':
                    // frontend.css line 802 forces td[style*="background-color"]
                    // to use var(--gt-cell-bg) via !important (for rowspan-merge
                    // bg coverage). Set both so the !important rule resolves to
                    // the intended color.
                    $cell.css({ 'background-color': setValue, '--gt-cell-bg': setValue });
                    break;
                case 'setCellContent':
                    if ($cell.find('.gt-editable-field').length > 0) {
                        $cell.find('.gt-editable-field').html(setValue);
                    } else {
                        $cell.html(setValue);
                    }
                    break;
                case 'setCellClass':
                    $cell.addClass(setValue);
                    break;
                case 'setRowColor':
                    $row.css('background-color', setValue);
                    break;
                case 'setRowClass':
                    $row.addClass(setValue);
                    break;
            }
        }

    });

})(window);

/* ===== assets/js/frontend/selection.js ===== */
/**
 * TableCrafter — frontend/selection.js
 *
 * Row selection state + bulk action toolbar wire-up. Fourth module
 * under #830.
 *
 * Closes #836.
 *
 * Surface (attached to GravityTable.prototype via Object.assign):
 *
 *   - bindSelectionEvents($wrapper)   — wires .gt-select-all change
 *                                       (toggle every gt-entry-checkbox)
 *                                       + .gt-bulk-action-btn click
 *                                       (delegates to performBulkAction
 *                                       when a non-empty action is
 *                                       selected). Called once from
 *                                       bindEvents in frontend.js.
 *
 *   - getSelectedEntryIds()           — collects values from every
 *                                       checked .gt-entry-checkbox
 *                                       inside the wrapper. Pure-DOM
 *                                       helper, no side effects.
 *
 *   - performBulkAction(action)       — collects selected ids; alerts
 *                                       and returns early when none;
 *                                       confirms on delete; POSTs to
 *                                       the gt_bulk_action AJAX
 *                                       endpoint; on success: alerts
 *                                       the response message, unchecks
 *                                       every checkbox (#130 — prevent
 *                                       stale selections after reload),
 *                                       reloads entries.
 *
 * Hot path note: getSelectedEntryIds is also reachable to any future
 * caller that needs the currently-checked rows without firing AJAX
 * (e.g. export "selected rows only", #836b future enhancement).
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery || window.$;

    Object.assign(window.GravityTable.prototype, {

        bindSelectionEvents: function ($wrapper) {
            var self = this;

            $wrapper.find('.gt-select-all').on('change', function () {
                var checked = $(this).prop('checked');
                $wrapper.find('.gt-entry-checkbox').prop('checked', checked);
            });

            $wrapper.find('.gt-bulk-action-btn').on('click', function () {
                var action = $wrapper.find('.gt-bulk-action-select').val();
                if (action) {
                    self.performBulkAction(action);
                }
            });
        },

        getSelectedEntryIds: function () {
            var ids = [];
            var $wrapper = $('#' + this.wrapperId);
            $wrapper.find('.gt-entry-checkbox:checked').each(function () {
                ids.push($(this).val());
            });
            return ids;
        },

        performBulkAction: function (action) {
            var self = this;
            var $wrapper = $('#' + this.wrapperId);
            var selectedEntries = self.getSelectedEntryIds();

            if (selectedEntries.length === 0) {
                alert('Please select entries to perform bulk action');
                return;
            }

            // #613 phase 2 slice 5 (v4.200.0) — intercept push_to_source and
            // delegate to pushRowToSource per entry. Reads each row's current
            // values from its tbody cells (keyed by data-field-id) so the
            // payload reflects the user's latest inline edits.
            // #613 phase 2 slice 6 (v4.201.0) — aggregate success/failure
            // feedback. After all per-row pushes complete, surface a single
            // alert with success and failure counts so the user sees the
            // outcome (previously silent).
            if (action === 'push_to_source') {
                if (typeof self.pushRowToSource !== 'function') {
                    /* c8 ignore next */
                    return;
                }
                var total = selectedEntries.length;
                var completed = 0;
                var succeeded = 0;
                var failed = 0;
                selectedEntries.forEach(function (rowId) {
                    var $row = $wrapper.find('tbody tr[data-entry-id="' + rowId + '"]');
                    var payload = {};
                    $row.find('td[data-field-id]').each(function () {
                        var $td = $(this);
                        var fieldId = String($td.attr('data-field-id'));
                        payload[fieldId] = ($td.text() || '').trim();
                    });
                    self.pushRowToSource(rowId, payload, function (response) {
                        completed++;
                        // #613 phase 2 slice 7 (v4.202.0) — per-row visual
                        // feedback. Mark each row with a class so CSS can
                        // tint it; on failure, surface the typed error code
                        // via a title attribute the user can hover to read.
                        $row.removeClass('gt-push-success gt-push-failed');
                        if (response && response.success) {
                            succeeded++;
                            $row.addClass('gt-push-success');
                            $row.removeAttr('title');
                        } else {
                            failed++;
                            $row.addClass('gt-push-failed');
                            var errCode = (response && response.data && response.data.code) ? response.data.code : 'push_error';
                            var errMsg  = (response && response.data && response.data.message) ? response.data.message : 'Push failed.';
                            $row.attr('title', errCode + ': ' + errMsg);
                        }
                        if (completed === total) {
                            if (failed === 0) {
                                alert('Pushed ' + succeeded + ' row' + (succeeded === 1 ? '' : 's') + ' successfully.');
                            } else {
                                alert('Pushed ' + succeeded + ' row' + (succeeded === 1 ? '' : 's') + ', ' + failed + ' failed.');
                            }
                        }
                    });
                });
                return;
            }

            if (action === 'delete') {
                if (!confirm('Are you sure you want to delete ' + selectedEntries.length + ' entries?')) {
                    return;
                }
            }

            var data = {
                action: 'gt_bulk_action',
                nonce: this.config.nonce,
                bulk_action: action,
                entry_ids: selectedEntries,
                table_id: this.config.table_id || 0
            };

            $.post(this.config.ajax_url, data, function (response) {
                if (response.success) {
                    alert(response.data.message);
                    // Uncheck all checkboxes before reloading so stale selections do not persist (#130).
                    $wrapper.find('.gt-entry-checkbox').prop('checked', false);
                    self.loadEntries();
                } else {
                    alert('Error performing bulk action: ' + response.data);
                }
            }).fail(function () {
                /* c8 ignore next */
                alert('Error performing bulk action');
            });
        }

    });

})(window);

/* ===== assets/js/frontend/pagination.js ===== */
/**
 * TableCrafter — frontend/pagination.js
 *
 * Pagination controls + length selector. Fifth module under #830.
 *
 * Closes #835.
 *
 * Surface (attached to GravityTable.prototype via Object.assign):
 *
 *   - computeTotalPages(data, perPage)
 *       Pure helper. Returns the page count given an entries response
 *       envelope. Prefers data.total_pages from the server, falls back
 *       to Math.ceil(data.total / perPage), then 1. Issue #185 fixed
 *       a hard-coded 1 fallback that hid pagination permanently on
 *       2,000+ row responses; this helper preserves that behavior.
 *
 *   - formatPaginationInfoText(template, start, end, total)
 *       Pure helper. {start} / {end} / {total} token replacement.
 *       Used by updatePagination + reachable to any future consumer
 *       that wants to render the same info string elsewhere.
 *
 *   - bindPaginationEvents($wrapper)
 *       Wires the length-selector change handler + prev / next page
 *       click handlers (delegated, .gt-table namespace so the
 *       $wrapper.off('.gt-table') re-mount guard cleans them up — #128).
 *       Called once from bindEvents in frontend.js.
 *
 *   - updatePagination(data)
 *       Orchestrates the DOM update per AJAX response. Toggles the
 *       gt-pagination-single-page class on single-page responses
 *       (CSS-class hiding so the show_pagination table setting wins
 *       over the single-page check — #185). Updates entry-count text,
 *       current/total page text, prev/next disabled state.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery || window.$;

    Object.assign(window.GravityTable.prototype, {

        computeTotalPages: function (data, perPage) {
            return data.total_pages
                || Math.ceil((data.total || 0) / perPage)
                || 1;
        },

        formatPaginationInfoText: function (template, start, end, total) {
            return String(template)
                .replace('{start}', start)
                .replace('{end}', end)
                .replace('{total}', total);
        },

        bindPaginationEvents: function ($wrapper) {
            var self = this;

            // Length selector — visitor-side per_page change. Mutates
            // this.config.per_page in place so subsequent loadEntries calls
            // pick up the new size, then reloads from page 1. -1 means "All";
            // server-side enforces the max via existing per_page caps.
            $wrapper.on('change.gt-table', '.gt-length-select', function () {
                var val = parseInt($(this).val(), 10);
                if (isNaN(val) || (val !== -1 && val < 1)) return;
                self.config.per_page = val;
                self.currentPage = 1;
                self.loadEntries();
            });

            // Pagination — delegated on $wrapper with .gt-table namespace so the
            // $wrapper.off('.gt-table') re-mount guard removes them cleanly (#128).
            $wrapper.on('click.gt-table', '.gt-prev-page', function () {
                if (!$(this).prop('disabled') && self.currentPage > 1) {
                    self.currentPage--;
                    self.loadEntries();
                }
            });

            $wrapper.on('click.gt-table', '.gt-next-page', function () {
                if (!$(this).prop('disabled')) {
                    self.currentPage++;
                    self.loadEntries();
                }
            });
        },

        updatePagination: function (data) {
            var $wrapper = $('#' + this.wrapperId);
            var $pagination = $wrapper.find('.gt-pagination-container');
            var perPage = this.config.per_page || 25;
            var totalPages = this.computeTotalPages(data, perPage);

            // Toggle a CSS class rather than setting an inline style so the
            // show_pagination table setting always wins over the single-page check (#185).
            if (totalPages <= 1) {
                $pagination.addClass('gt-pagination-single-page');
                return;
            } else {
                $pagination.removeClass('gt-pagination-single-page');
            }

            var start = (this.currentPage - 1) * this.config.per_page + 1;
            var end = Math.min(this.currentPage * this.config.per_page, data.total);
            var total = data.total;

            var gtLabels = (this.config && this.config.pagination_labels) || {};
            var gtInfoText = gtLabels.info_text || 'Showing {start} to {end} of {total} entries';
            $wrapper.find('.gt-entry-count').text(
                this.formatPaginationInfoText(gtInfoText, start, end, total)
            );

            $wrapper.find('.gt-current-page').text(this.currentPage);
            $wrapper.find('.gt-total-pages').text('of ' + totalPages);

            var $prevBtn = $wrapper.find('.gt-prev-page');
            var $nextBtn = $wrapper.find('.gt-next-page');

            $prevBtn.prop('disabled', this.currentPage <= 1);
            $nextBtn.prop('disabled', this.currentPage >= totalPages);
        }

    });

})(window);

/* ===== assets/js/frontend/a11y-keyboard.js ===== */
/**
 * TableCrafter — frontend/a11y-keyboard.js
 *
 * Row-link interaction (click + auxclick + keyboard activation) and
 * the isResponsiveMode helper. Sixth module under #830.
 *
 * Closes #839.
 *
 * Scope note: the umbrella issue estimated ~300 lines of "a11y +
 * keyboard nav" code in this module. In practice the keyboard handlers
 * in frontend.js are tightly coupled to their feature contexts (date
 * inputs, toggle switches, detail-row chevrons, undo/redo, etc.) and
 * don't factor cleanly into a single module without churning unrelated
 * feature code. The cohesive a11y block that DOES factor cleanly is
 * row-link interaction (#567 slice 2.x):
 *
 *   - click on a "blank" cell of a clickable row navigates to data-row-href
 *   - Cmd/Ctrl/Shift+click and middle-click open in a new tab with
 *     noopener,noreferrer
 *   - Enter/Space on the focused row navigates (with the same
 *     modifier rules) — keyboard parity with the click handler
 *   - inline interactives (a, button, input, textarea, select, label,
 *     editable cells, toggle switches, action cells, checkbox cells)
 *     suppress row navigation so the user can still click links and
 *     buttons inside the row
 *   - row_link_open_new_tab config opens every navigation in a new tab
 *
 * isResponsiveMode() is the only remaining standalone a11y-adjacent
 * helper. It comes along because it's a one-liner used by sort
 * handlers (frontend.js) to suppress sorting in card-view mode where
 * column headers aren't visible.
 *
 * Surface (attached to GravityTable.prototype via Object.assign):
 *   - bindRowLinkEvents($wrapper)   — wires click + auxclick + keydown.
 *   - isResponsiveMode()            — pure-ish helper.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery || window.$;

    // Inline interactives that suppress row navigation. The selector list
    // must cover real links, buttons, inputs, editable cells, toggle
    // switches, action cells, and checkbox cells — only the "blank" cell
    // areas of a clickable row should fire the navigation.
    var INLINE_INTERACTIVE_SELECTOR =
        'a, button, input, textarea, select, label, .gt-editable-cell, .gt-toggle-switch, .gt-actions-cell, .gt-checkbox-cell';

    Object.assign(window.GravityTable.prototype, {

        // Indirection layer so the test harness can stub navigation without
        // having to mutate window.location (jsdom blocks Object.defineProperty
        // on Location.href). Production behavior is unchanged: same-tab path
        // assigns location.href; new-tab path calls window.open with
        // noopener,noreferrer (security + privacy hardening — prevents the
        // opened page from reaching window.opener).
        _navigate: function (href, newTab) {
            if (newTab) {
                window.open(href, '_blank', 'noopener,noreferrer');
            } else {
                /* c8 ignore next */
                window.location.href = href;
            }
        },

        bindRowLinkEvents: function ($wrapper) {
            var self = this;

            function navigateRow(e, $row) {
                if ($(e.target).closest(INLINE_INTERACTIVE_SELECTOR).length > 0) {
                    return false;
                }
                var href = $row.attr('data-row-href');
                if (!href) { return false; }

                // #567 slice 2.4: per-table always-new-tab override.
                var alwaysNewTab = !!(self.config && self.config.row_link_open_new_tab);
                // Coerce to strict bool — keyboard events may have undefined
                // modifier keys instead of false (synthetic-event quirk).
                var newTab = !!(alwaysNewTab || e.ctrlKey || e.metaKey || e.shiftKey || e.button === 1);
                self._navigate(href, newTab);
                return true;
            }

            $wrapper.on('click.gt-table', 'tr.gt-row-clickable', function (e) {
                navigateRow(e, $(this));
            });

            // auxclick fires on middle-click and right-click. We only act on
            // middle-click (button === 1); right-click stays alone so the
            // browser context menu still works.
            $wrapper.on('auxclick.gt-table', 'tr.gt-row-clickable', function (e) {
                if (e.button !== 1) { return; }
                if (navigateRow(e, $(this))) { e.preventDefault(); }
            });

            // Keyboard activation — Enter or Space on the focused row.
            $wrapper.on('keydown.gt-table', 'tr.gt-row-clickable', function (e) {
                if (e.key !== 'Enter' && e.key !== ' ') { return; }
                if ($(e.target).closest(INLINE_INTERACTIVE_SELECTOR).length > 0) { return; }
                var href = $(this).attr('data-row-href');
                if (!href) { return; }
                e.preventDefault(); // avoid page-scroll on Space

                // #567 slice 2.4: keyboard path also honors the always-new-tab override.
                var alwaysNewTab = !!(self.config && self.config.row_link_open_new_tab);
                var newTab = !!(alwaysNewTab || e.ctrlKey || e.metaKey || e.shiftKey);
                self._navigate(href, newTab);
            });
        },

        isResponsiveMode: function () {
            var $wrapper = $('#' + this.wrapperId);

            // Card view mode based on screen size + responsive settings.
            if (this.config.responsive_mode === 'enhanced') {
                return window.innerWidth <= 768 && $wrapper.hasClass('gt-mobile-card-view');
            }

            return false;
        }

    });

})(window);

/* ===== assets/js/frontend/detail-popup.js ===== */
/**
 * TableCrafter — frontend/detail-popup.js
 *
 * Entry-details popup overlay + file-upload cell renderer. Seventh
 * module under #830.
 *
 * Closes #837.
 *
 * Surface (attached to GravityTable.prototype via Object.assign):
 *
 *   - viewEntryDetails(entryId)
 *       AJAX-fetches entry details from the gt_get_entry_details
 *       endpoint, builds the popup HTML (title, label/value grid,
 *       optional GF edit link, edit button when frontend editing is
 *       enabled), and hands the result to showDetailsPopup. Loading
 *       and error states are surfaced as inline content.
 *
 *   - showDetailsPopup(content, options)
 *       Creates the .gt-popup-overlay DOM lazily on first call and
 *       reuses it on subsequent calls. Wires close handlers (X button,
 *       backdrop click, Escape key, .gt-close-popup) and a focus trap
 *       (Tab/Shift+Tab cycling between first/last focusable). Edit
 *       button click closes the popup and triggers inline-edit for
 *       the captured entry id. options.titleId attaches
 *       aria-labelledby on the modal; omit it to remove the attribute.
 *
 *   - closeDetailsPopup()
 *       Fades the overlay out, removes the gt-modal-open body class,
 *       and restores focus to the element that had it before the
 *       popup opened.
 *
 *   - bindDetailViewEvents($wrapper)
 *       Wires the .gt-view-detail / .gt-view-action click handlers
 *       that delegate to viewEntryDetails. Previously inlined in
 *       bindEntryEvents; now called from there in one line.
 *
 *   - renderFileUploadCell(value)
 *       Renders a fileupload field value as <img> (for known image
 *       extensions: jpg, jpeg, png, gif, webp, bmp, svg) or as an
 *       <a> link otherwise. Handles single URLs, JSON-encoded
 *       multi-file arrays, and gracefully falls back to single-URL
 *       render for non-JSON strings. URL + filename are escaped via
 *       this.escapeHtml (provided by util.js) to prevent injection.
 *
 * Pre-requisites: util.js (escapeHtml) must load before this module.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery || window.$;

    var IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'];

    Object.assign(window.GravityTable.prototype, {

        viewEntryDetails: function (entryId) {
            var self = this;

            self.showDetailsPopup('<p class="gt-details-loading">Loading details&hellip;</p>', { titleId: null });

            $.post(this.config.ajax_url, {
                action: 'gt_get_entry_details',
                nonce: this.config.nonce,
                entry_id: entryId
            }, function (response) {
                if (response.success) {
                    var data = response.data;
                    var titleId = 'gt-details-title-' + data.entry_id;
                    var canEdit = !!self.config.enable_frontend_editing;
                    var html = '<div class="gt-details-container">';
                    html += '<h3 id="' + titleId + '" class="gt-details-title">' + self.escapeHtml(data.form_title) + ' &mdash; Entry #' + self.escapeHtml(String(data.entry_id)) + '</h3>';
                    html += '<dl class="gt-details-grid">';
                    $.each(data.details, function (index, detail) {
                        html += '<div class="gt-details-row">';
                        html += '<dt class="gt-detail-label">' + self.escapeHtml(detail.label) + '</dt>';
                        html += '<dd class="gt-detail-value">' + (detail.value === '' || detail.value == null ? '<span class="gt-detail-empty">&mdash;</span>' : detail.value) + '</dd>';
                        html += '</div>';
                    });
                    html += '</dl>';
                    if (data.gf_entry_edit_url) {
                        html += '<p class="gt-details-gf-link"><a href="' + self.escapeHtml(data.gf_entry_edit_url) + '" target="_blank" rel="noopener noreferrer">Open in Gravity Forms</a></p>';
                    }
                    html += '</div>';
                    html += '<div class="gt-details-footer">';
                    if (canEdit) {
                        html += '<button type="button" class="gt-details-edit-btn" data-entry-id="' + self.escapeHtml(String(data.entry_id)) + '">Edit</button>';
                    }
                    html += '<button type="button" class="gt-close-popup">Close</button>';
                    html += '</div>';

                    self.showDetailsPopup(html, { titleId: titleId });
                } else {
                    self.showDetailsPopup('<p class="gt-details-error">Error: ' + self.escapeHtml(String(response.data || 'Unknown error')) + '</p>', { titleId: null });
                }
            }).fail(function () {
                self.showDetailsPopup('<p class="gt-details-error">Failed to load entry details.</p>', { titleId: null });
            });
        },

        showDetailsPopup: function (content, options) {
            var self = this;
            options = options || {};
            var $overlay = $('.gt-popup-overlay');

            if (!$overlay.length) {
                $overlay = $('<div class="gt-popup-overlay" aria-hidden="true">'
                    + '<div class="gt-popup-modal" role="dialog" aria-modal="true" tabindex="-1">'
                    + '<button type="button" class="gt-popup-close" aria-label="Close">&times;</button>'
                    + '<div class="gt-popup-body"></div>'
                    + '</div></div>');
                $('body').append($overlay);

                $overlay.on('click', function (e) {
                    if ($(e.target).hasClass('gt-popup-overlay') || $(e.target).hasClass('gt-popup-close')) {
                        self.closeDetailsPopup();
                    }
                });

                $overlay.on('click', '.gt-close-popup', function () {
                    self.closeDetailsPopup();
                });

                $overlay.on('click', '.gt-details-edit-btn', function () {
                    var entryId = $(this).data('entry-id');
                    self.closeDetailsPopup();
                    setTimeout(function () { self.triggerInlineEditForEntry(entryId); }, 0);
                });

                $overlay.on('keydown', function (e) {
                    if (e.key === 'Escape') {
                        e.preventDefault();
                        self.closeDetailsPopup();
                        return;
                    }
                    if (e.key === 'Tab') {
                        var $focusables = $overlay.find('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])').filter(':visible');
                        if (!$focusables.length) return;
                        var first = $focusables.first()[0];
                        var last = $focusables.last()[0];
                        if (e.shiftKey && document.activeElement === first) {
                            e.preventDefault();
                            last.focus();
                        } else if (!e.shiftKey && document.activeElement === last) {
                            e.preventDefault();
                            first.focus();
                        }
                    }
                });
            }

            var $modal = $overlay.find('.gt-popup-modal');
            if (options.titleId) {
                $modal.attr('aria-labelledby', options.titleId);
            } else {
                $modal.removeAttr('aria-labelledby');
            }

            $overlay.find('.gt-popup-body').html(content);
            $overlay.attr('aria-hidden', 'false');
            $overlay.fadeIn(200, function () {
                $modal.focus();
            });
            $('body').addClass('gt-modal-open');

            if (!self._gtPreviousFocus) {
                self._gtPreviousFocus = document.activeElement;
            }
        },

        closeDetailsPopup: function () {
            var self = this;
            var $overlay = $('.gt-popup-overlay');
            $overlay.attr('aria-hidden', 'true');
            $overlay.fadeOut(200);
            $('body').removeClass('gt-modal-open');
            if (self._gtPreviousFocus && typeof self._gtPreviousFocus.focus === 'function') {
                try { self._gtPreviousFocus.focus(); } catch (err) {}
            }
            self._gtPreviousFocus = null;
        },

        bindDetailViewEvents: function ($wrapper) {
            var self = this;
            $wrapper.on('click', '.gt-view-detail, .gt-view-action', function (e) {
                e.preventDefault();
                e.stopPropagation();
                var entryId = $(this).data('entry-id');
                self.viewEntryDetails(entryId);
            });
        },

        renderFileUploadCell: function (value) {
            var self = this;
            var urls = [];
            if (!value) return '';

            // Try JSON array first (multi-file upload).
            try {
                var parsed = JSON.parse(value);
                if (Array.isArray(parsed)) {
                    urls = parsed;
                } else {
                    urls = [String(value)];
                }
            } catch (e) {
                urls = [String(value)];
            }

            var html = '';

            urls.forEach(function (url) {
                url = String(url).trim();
                if (!url) return;
                var ext = url.split('.').pop().toLowerCase().split('?')[0];
                var name = url.split('/').pop().split('?')[0];
                if (IMAGE_EXTENSIONS.indexOf(ext) !== -1) {
                    html += '<img src="' + self.escapeHtml(url) + '" alt="' + self.escapeHtml(name) + '" class="gt-file-image" loading="lazy" style="max-width:80px;max-height:60px;object-fit:contain;" />';
                } else {
                    html += '<a href="' + self.escapeHtml(url) + '" class="gt-file-link" target="_blank" rel="noopener noreferrer">' + self.escapeHtml(name) + '</a> ';
                }
            });

            return html;
        }

    });

})(window);

/* ===== assets/js/frontend/search.js ===== */
/**
 * TableCrafter — frontend/search.js
 *
 * Search controls (search button + Enter keypress on search input).
 * First slice of #834 (the larger filter-sort surface).
 *
 * Closes #834 slice 1.
 *
 * Surface (attached to GravityTable.prototype via Object.assign):
 *
 *   - bindSearchEvents($wrapper)
 *       Wires .gt-search-btn click + .gt-search-input keypress(Enter).
 *       Both flows: read input value, set self.searchTerm, reset
 *       currentPage to 1, persist filter state to localStorage, reload
 *       entries. Previously inlined in frontend.js's bindEvents.
 *
 * Scope-honest note (#834): the umbrella estimated ~700 lines for
 * filter-sort, but the filter, sort, and search handlers are tightly
 * intertwined in bindEvents — splitting them all in one PR would be
 * high-risk. This module ships ONLY the search controls (smallest
 * cohesive sub-piece). Sort handlers, per-column filter inputs,
 * applyFilters / clearFilters, and initTextFilterTypeaheads remain in
 * frontend.js for future slices.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery || window.$;

    Object.assign(window.GravityTable.prototype, {

        bindSearchEvents: function ($wrapper) {
            var self = this;

            $wrapper.find('.gt-search-btn').on('click', function () {
                self.searchTerm = $wrapper.find('.gt-search-input').val();
                self.currentPage = 1;
                self.persistFilterStateLocal();
                self.loadEntries();
            });

            $wrapper.find('.gt-search-input').on('keypress', function (e) {
                if (e.key === 'Enter') {
                    self.searchTerm = $(this).val();
                    self.currentPage = 1;
                    self.persistFilterStateLocal();
                    self.loadEntries();
                }
            });
        }

    });

})(window);

/* ===== assets/js/frontend/search-grammar.js ===== */
/**
 * TableCrafter — frontend/search-grammar.js
 *
 * #2278 Phase 1 — advanced global search grammar (client-side only).
 *
 * Ports the query tokenizer and AST evaluator from tablecrafter.js
 * (_tokenizeQuery / _evalQueryAst) into a frontend module attached to
 * GravityTable.prototype. Covers: implicit AND, OR (case-insensitive),
 * -negation, "quoted phrases", field:value with column-label resolution,
 * comparison operators (> >= < <=), wildcards (* ?), regex.
 *
 * Non-SSP wire-in: when the global search term contains grammar
 * operators the main search path (load-entries.js) calls
 * grammarFilterEntries() to evaluate the AST client-side over the
 * loaded row set; server-side LIKE search is suppressed so the grammar
 * filter operates over the full (unfiltered) page. Plain queries fall
 * through to the existing behavior byte-identical.
 *
 * Fuzzy mode: when enable_fuzzy_search is set and the AST contains any
 * `field`, `not`, or `or` node, grammar evaluation wins over fuzzy.
 * Collect highlight terms via _getGrammarHighlightTerms() — returns []
 * when fuzzy-disable nodes are present (per spec, suppress highlighting
 * for not/comparison/or nodes).
 *
 * Field resolution: matched against col.label (case-insensitive,
 * whitespace collapsed to _) and col.field_id. Falls back to raw key
 * lookup in the row object.
 *
 * Surface (attached to GravityTable.prototype via Object.assign):
 *   _hasGrammarOperators(query)
 *   parseSearchGrammarQuery(input)         — returns AST root
 *   _evalGrammarAst(node, row)
 *   _resolveColumnValue(row, fieldName)
 *   _grammarAstHasFuzzyDisableNode(ast)
 *   _getGrammarHighlightTerms(ast)
 *   grammarFilterEntries(entries, query)
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        window.GravityTable = function GravityTable() {};
    }

    // ── Closure-private helpers ─────────────────────────────────────────

    /**
     * Convert a glob-style wildcard pattern (* ?) into a RegExp.
     * Anchored to full string; case-insensitive.
     */
    function wildcardToRegex(pattern) {
        var escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&')
            .replace(/\*/g, '.*')
            .replace(/\?/g, '.');
        return new RegExp('^' + escaped + '$', 'i');
    }

    /**
     * Evaluate a resolved field value against the operator/value from
     * a `field` AST node.  Ported from tablecrafter.js _evalFieldNode.
     */
    function evalFieldNode(node, raw) {
        var op    = node.op;
        var value = node.value;

        if (op === 'regex') {
            try {
                var m  = value.match(/^\/(.*)\/([gimsuy]*)$/);
                var re = m ? new RegExp(m[1], m[2]) : new RegExp(value, 'i');
                return re.test(String(raw == null ? '' : raw));
            } catch (_) {
                return String(raw == null ? '' : raw).toLowerCase().indexOf(value.toLowerCase()) !== -1;
            }
        }

        if (op === 'gt' || op === 'gte' || op === 'lt' || op === 'lte') {
            var num = parseFloat(raw);
            var cmp = parseFloat(value);
            if (isNaN(num) || isNaN(cmp)) { return false; }
            if (op === 'gt')  { return num > cmp; }
            if (op === 'gte') { return num >= cmp; }
            if (op === 'lt')  { return num < cmp; }
            /* op === 'lte' */ return num <= cmp;
        }

        var cellStr = String(raw == null ? '' : raw);
        var valStr  = String(value);

        if (op === 'eq') {
            if (valStr.indexOf('*') !== -1 || valStr.indexOf('?') !== -1) {
                return wildcardToRegex(valStr).test(cellStr);
            }
            return cellStr.toLowerCase().indexOf(valStr.toLowerCase()) !== -1;
        }

        return cellStr.toLowerCase().indexOf(valStr.toLowerCase()) !== -1;
    }

    /**
     * Read a double-quoted string starting at startIdx.
     * Returns { value, next } where next is the index after the
     * closing quote (or end-of-string when unclosed).
     */
    function readQuoted(s, startIdx) {
        var end = s.indexOf('"', startIdx + 1);
        if (end === -1) {
            return { value: s.slice(startIdx + 1), next: s.length };
        }
        return { value: s.slice(startIdx + 1, end), next: end + 1 };
    }

    /**
     * Tokenize a search query string.
     * Ported from tablecrafter.js _tokenizeQuery.
     *
     * Token shapes:
     *   { type: 'not' }
     *   { type: 'or' }
     *   { type: 'phrase', value: string }
     *   { type: 'term',   value: string }
     *   { type: 'field',  field: string, op: string, value: string }
     */
    function tokenizeQuery(s) {
        var tokens = [];
        var i      = 0;

        while (i < s.length) {
            var ch = s[i];

            // Skip whitespace
            if (/\s/.test(ch)) { i++; continue; }

            // Negation prefix: - immediately followed by non-whitespace
            if (ch === '-' && i + 1 < s.length && !/\s/.test(s[i + 1])) {
                tokens.push({ type: 'not' });
                i++;
                continue;
            }

            // Quoted phrase
            if (ch === '"') {
                var q = readQuoted(s, i);
                tokens.push({ type: 'phrase', value: q.value });
                i = q.next;
                continue;
            }

            // Read a bare word (stops at whitespace, quote, colon)
            var wordStart = i;
            while (i < s.length && !/[\s":]/.test(s[i])) { i++; }
            var word = s.slice(wordStart, i);

            // OR keyword (case-insensitive)
            if (word.toUpperCase() === 'OR') {
                tokens.push({ type: 'or' });
                continue;
            }

            // field: prefix
            if (i < s.length && s[i] === ':') {
                i++; // consume colon
                var fieldValue = '';
                var op = 'eq';

                // Comparison operators immediately after colon
                if (i < s.length) {
                    if (s[i] === '>' && i + 1 < s.length && s[i + 1] === '=') { op = 'gte'; i += 2; }
                    else if (s[i] === '<' && i + 1 < s.length && s[i + 1] === '=') { op = 'lte'; i += 2; }
                    else if (s[i] === '>') { op = 'gt'; i++; }
                    else if (s[i] === '<') { op = 'lt'; i++; }
                    else if (s[i] === '=') { op = 'eq'; i++; }
                }

                // Quoted value
                if (i < s.length && s[i] === '"') {
                    var qv = readQuoted(s, i);
                    fieldValue = qv.value;
                    i = qv.next;
                } else if (i < s.length && s[i] === '/') {
                    // Regex literal: /pattern/flags
                    op = 'regex';
                    var regexStart = i;
                    i++; // skip opening /
                    while (i < s.length && s[i] !== '/' && s[i] !== ' ') { i++; }
                    if (i < s.length && s[i] === '/') {
                        i++; // skip closing /
                        while (i < s.length && /[gimsuy]/.test(s[i])) { i++; }
                    }
                    fieldValue = s.slice(regexStart, i);
                } else {
                    // Bare value: read to next whitespace
                    var valueStart = i;
                    while (i < s.length && !/\s/.test(s[i])) { i++; }
                    fieldValue = s.slice(valueStart, i);
                }

                tokens.push({ type: 'field', field: word, op: op, value: fieldValue });
                continue;
            }

            // Plain term
            if (word.length > 0) {
                tokens.push({ type: 'term', value: word });
            }
        }

        return tokens;
    }

    /**
     * Consume one logical node from the token stream starting at index i.
     * Returns { node, next } where next is the updated token index.
     */
    function consumeQueryNode(tokens, i) {
        var tok = tokens[i];

        if (tok.type === 'not') {
            if (i + 1 >= tokens.length) {
                return { node: { type: 'term', value: '' }, next: i + 1 };
            }
            var inner = consumeQueryNode(tokens, i + 1);
            return { node: { type: 'not', child: inner.node }, next: inner.next };
        }

        if (tok.type === 'phrase') {
            return { node: { type: 'phrase', value: tok.value }, next: i + 1 };
        }

        if (tok.type === 'field') {
            return {
                node: { type: 'field', field: tok.field, op: tok.op || 'eq', value: tok.value },
                next: i + 1
            };
        }

        // default: term
        return { node: { type: 'term', value: tok.value }, next: i + 1 };
    }

    /**
     * Build an AST from a token stream.
     * Handles implicit AND (juxtaposition) and explicit OR.
     * Returns a root { type: 'and', children } node.
     * Ported from tablecrafter.js parseQuery.
     */
    function buildAst(tokens) {
        var children = [];
        var i = 0;

        while (i < tokens.length) {
            var tok = tokens[i];

            if (tok.type === 'or') {
                var prev = children.pop();
                i++;
                if (i >= tokens.length) {
                    if (prev) { children.push(prev); }
                    break;
                }
                var consumed = consumeQueryNode(tokens, i);
                i = consumed.next;
                var orNode;
                if (prev && prev.type === 'or') {
                    orNode = { type: 'or', children: prev.children.concat([consumed.node]) };
                } else {
                    orNode = { type: 'or', children: [prev || { type: 'term', value: '' }, consumed.node] };
                }
                children.push(orNode);
            } else {
                var c = consumeQueryNode(tokens, i);
                i = c.next;
                children.push(c.node);
            }
        }

        return { type: 'and', children: children };
    }

    /**
     * Normalize a column label for field name matching.
     * Lowercases and collapses whitespace runs to a single underscore.
     */
    function normalizeLabel(s) {
        return String(s == null ? '' : s).toLowerCase().replace(/\s+/g, '_');
    }

    // ── Prototype surface ───────────────────────────────────────────────

    Object.assign(window.GravityTable.prototype, {

        /**
         * Returns true when the query string contains any grammar operator
         * tokens: colon (field:), double-quote (phrase), word-OR, or
         * leading-dash negation (-word).
         *
         * Plain term-only queries return false so existing LIKE-based
         * search runs unchanged.
         */
        _hasGrammarOperators: function (query) {
            if (typeof query !== 'string' || query.trim() === '') { return false; }
            if (query.indexOf('"') !== -1 || query.indexOf(':') !== -1) { return true; }
            if (/\bOR\b/i.test(query)) { return true; }
            // -word: dash not followed by whitespace (negation prefix)
            if (/-[^\s]/.test(query)) { return true; }
            return false;
        },

        /**
         * Tokenize and parse a query string into an AST.
         * Public entry-point for tests and callers that need the AST directly.
         */
        parseSearchGrammarQuery: function (input) {
            var s = String(input == null ? '' : input);
            return buildAst(tokenizeQuery(s));
        },

        /**
         * Resolve a field name token (from field:value) to the raw cell
         * value stored in the row object.
         *
         * Priority order:
         *  1. Column whose label (normalised) matches fieldName
         *  2. Column whose field_id matches fieldName exactly
         *  3. Raw key lookup in the row object (fallback)
         */
        _resolveColumnValue: function (row, fieldName) {
            var cols = this.config && Array.isArray(this.config.columns)
                ? this.config.columns : [];
            var norm = normalizeLabel(fieldName);

            for (var i = 0; i < cols.length; i++) {
                var col = cols[i];
                if (normalizeLabel(col.label) === norm) {
                    return row[col.field_id];
                }
                if (String(col.field_id) === String(fieldName)) {
                    return row[col.field_id];
                }
            }

            // Raw key fallback (allows direct field-id or arbitrary key lookup)
            return row[fieldName];
        },

        /**
         * Evaluate an AST node against a single row object.
         *
         * Ported from tablecrafter.js _evalQueryAst, extended with
         * column-label field resolution via _resolveColumnValue.
         */
        _evalGrammarAst: function (node, row) {
            var self = this;

            switch (node.type) {
                case 'and':
                    return node.children.every(function (c) {
                        return self._evalGrammarAst(c, row);
                    });

                case 'or':
                    return node.children.some(function (c) {
                        return self._evalGrammarAst(c, row);
                    });

                case 'not':
                    return !self._evalGrammarAst(node.child, row);

                case 'phrase': {
                    var needle = node.value.toLowerCase();
                    return Object.values(row).some(function (v) {
                        return v != null && String(v).toLowerCase().indexOf(needle) !== -1;
                    });
                }

                case 'term': {
                    var pattern = node.value;
                    if (pattern.indexOf('*') !== -1 || pattern.indexOf('?') !== -1) {
                        var re = wildcardToRegex(pattern);
                        return Object.values(row).some(function (v) {
                            return v != null && re.test(String(v));
                        });
                    }
                    var termNeedle = pattern.toLowerCase();
                    return Object.values(row).some(function (v) {
                        return v != null && String(v).toLowerCase().indexOf(termNeedle) !== -1;
                    });
                }

                case 'field': {
                    var raw = self._resolveColumnValue(row, node.field);
                    return evalFieldNode(node, raw);
                }

                default:
                    return true;
            }
        },

        /**
         * Returns true when the AST contains any node whose type would
         * disable fuzzy search: 'field', 'not', or 'or'.
         *
         * Used by callers to gate enable_fuzzy_search: if this returns
         * true, grammar evaluation takes precedence over fuzzy matching.
         */
        _grammarAstHasFuzzyDisableNode: function (ast) {
            if (!ast) { return false; }
            var self = this;
            if (ast.type === 'field' || ast.type === 'not' || ast.type === 'or') {
                return true;
            }
            if (Array.isArray(ast.children)) {
                if (ast.children.some(function (c) {
                    return self._grammarAstHasFuzzyDisableNode(c);
                })) { return true; }
            }
            if (ast.child && self._grammarAstHasFuzzyDisableNode(ast.child)) {
                return true;
            }
            return false;
        },

        /**
         * Collect leaf term/phrase values from the AST for DOM highlighting.
         *
         * Returns [] when the AST contains any fuzzy-disable node (field,
         * not, or), because highlighting is suppressed in those cases
         * (per spec: "Suppress highlighting when not, comparison operators,
         * or or nodes are present").
         */
        _getGrammarHighlightTerms: function (ast) {
            if (!ast) { return []; }
            if (this._grammarAstHasFuzzyDisableNode(ast)) { return []; }

            var terms = [];
            (function collect(node) {
                if ((node.type === 'term' || node.type === 'phrase') && node.value) {
                    terms.push(node.value);
                }
                if (Array.isArray(node.children)) { node.children.forEach(collect); }
                if (node.child) { collect(node.child); }
            }(ast));

            return terms;
        },

        /**
         * Filter an entries array by evaluating the grammar AST for query.
         *
         * When query contains no grammar operators the original array
         * reference is returned unchanged so the existing LIKE-based
         * search path is byte-identical for plain queries (regression-safe).
         *
         * When grammar operators are present, the query is parsed into an
         * AST and each entry is evaluated against it. Only matching entries
         * are returned (new array).
         */
        grammarFilterEntries: function (entries, query) {
            if (!Array.isArray(entries) || entries.length === 0) { return entries; }
            if (!this._hasGrammarOperators(query)) { return entries; }

            var ast  = buildAst(tokenizeQuery(String(query)));
            var self = this;

            return entries.filter(function (row) {
                return self._evalGrammarAst(ast, row);
            });
        }

    });

})(window);

/* ===== assets/js/frontend/filter-panel.js ===== */
/**
 * TableCrafter — frontend/filter-panel.js
 *
 * Advanced filter panel controls. #834 slice 2 of N.
 *
 * Surface (attached to GravityTable.prototype via Object.assign):
 *
 *   - bindFilterPanelEvents($wrapper)
 *       Wires 5 handlers in one call (all previously inlined in
 *       frontend.js's bindEvents):
 *         1. .gt-toggle-filters click — slideToggle the panel, flip
 *            button label ("Show Filters" / "Hide Filters") + arrow
 *            (▼/▲) + active class.
 *         2. .gt-apply-filters click — delegates to self.applyFilters().
 *         3. .gt-clear-filters click — delegates to self.clearFilters().
 *         4. .gt-filter-input keypress(Enter) — delegates to applyFilters.
 *         5. select.gt-filter-input change — delegates to applyFilters
 *            (text-input change deliberately does NOT trigger to avoid
 *            partial-typing noise; Enter or button click only).
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery || window.$;

    Object.assign(window.GravityTable.prototype, {

        bindFilterPanelEvents: function ($wrapper) {
            var self = this;

            // Advanced filters — toggle panel visibility.
            $wrapper.find('.gt-toggle-filters').on('click', function () {
                var $filtersPanel = $wrapper.find('.gt-filters-panel');
                var $button = $(this);
                var $arrow = $button.find('.gt-filter-arrow');
                var $text = $button.find('.gt-filter-text');

                // Prevent multiple clicks during animation.
                if ($filtersPanel.is(':animated')) {
                    return;
                }

                $filtersPanel.slideToggle(300, function () {
                    if ($filtersPanel.is(':visible')) {
                        $text.text($button.data('hide-text') || 'Hide Filters');
                        $arrow.text('▲');
                        $button.addClass('active');
                    } else {
                        $text.text($button.data('show-text') || 'Advanced Filters');
                        $arrow.text('▼');
                        $button.removeClass('active');
                    }
                });
            });

            // Filter actions.
            $wrapper.find('.gt-apply-filters').on('click', function () {
                self.applyFilters();
            });

            $wrapper.find('.gt-clear-filters').on('click', function () {
                self.clearFilters();
            });

            // Apply filters on Enter key in filter inputs.
            $wrapper.find('.gt-filter-input').on('keypress', function (e) {
                if (e.key === 'Enter') {
                    self.applyFilters();
                }
            });

            // Apply filters on change for select fields (including lookup
            // dropdowns). Text-input change deliberately does NOT trigger
            // — Enter or button click only — to avoid partial-typing noise.
            $wrapper.find('select.gt-filter-input, .gt-filter-input[type="select"], .gt-filter-input:not([type])').on('change', function () {
                if ($(this).is('select')) {
                    self.applyFilters();
                }
            });
        }

    });

})(window);

/* ===== assets/js/frontend/sort.js ===== */
/**
 * TableCrafter — frontend/sort.js
 *
 * Sort handler + multi-column shift-click state machine. #834 slice 3 of N.
 *
 * Surface (attached to GravityTable.prototype via Object.assign):
 *
 *   - computeNextSortState({stack, field, isShift, multiSortEnabled,
 *                          currentField, currentOrder})
 *       Pure state-machine helper. Given the current sort state and a
 *       click event's field + shift state, returns the next state as
 *       {stack, field, order, error?}. All multi-sort rules live here:
 *         - shift-click + multi-sort enabled: add field to stack or
 *           flip its direction in place. Cap at 3 (replace last when
 *           full).
 *         - plain click or multi-sort disabled: reset to single-column.
 *         - same-field plain click toggles asc <-> desc.
 *         - invalid (empty) field returns {error}.
 *       Pure: no DOM, no side effects, no jQuery.
 *
 *   - bindSortEvents($wrapper)
 *       Wires the .gt-sortable click handler. On click:
 *         1. Skip when click is inside .gt-resizer (column resize gesture).
 *         2. Skip when isResponsiveMode() — card view has no headers.
 *         3. Validate field; if missing from config.columns, push it.
 *         4. Call computeNextSortState to mutate stack/field/order.
 *         5. Call self.loadEntries() to refetch with new sort.
 *         6. Update DOM: gt-sort-indicator active class, aria-sort
 *            attribute, numbered badges (1=primary, 2=secondary, etc).
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery || window.$;

    Object.assign(window.GravityTable.prototype, {

        computeNextSortState: function (input) {
            var stack = (input && input.stack) ? input.stack.slice() : [];
            var field = input && input.field;
            var isShift = !!(input && input.isShift);
            var multiSortEnabled = !!(input && input.multiSortEnabled);
            var currentField = input && input.currentField;
            var currentOrder = input && input.currentOrder;

            if (!field) {
                return { error: 'missing field', stack: stack, field: currentField, order: currentOrder };
            }

            var order = 'asc';

            if (isShift && multiSortEnabled) {
                // Find existing entry for this column in the stack.
                var existingIdx = -1;
                for (var i = 0; i < stack.length; i++) {
                    if (stack[i].column_id === field) { existingIdx = i; break; }
                }
                if (existingIdx >= 0) {
                    // Flip direction in place.
                    var existing = { column_id: stack[existingIdx].column_id, direction: stack[existingIdx].direction === 'asc' ? 'desc' : 'asc' };
                    stack[existingIdx] = existing;
                    if (existingIdx === 0) {
                        order = existing.direction;
                    }
                } else {
                    // New column added — cap at 3.
                    if (stack.length < 3) {
                        stack.push({ column_id: field, direction: 'asc' });
                    } else {
                        stack[stack.length - 1] = { column_id: field, direction: 'asc' };
                    }
                }
            } else {
                // Plain click — reset to single-column.
                if (currentField === field && currentOrder === 'asc') {
                    order = 'desc';
                }
                stack = [{ column_id: field, direction: order }];
            }

            return {
                stack: stack,
                field: stack[0].column_id,
                order: stack[0].direction,
            };
        },

        bindSortEvents: function ($wrapper) {
            var self = this;

            $wrapper.on('click.gt-table', '.gt-sortable', function (e) {
                // Skip when clicking the resize handle.
                if ($(e.target).closest('.gt-resizer').length) {
                    return;
                }
                // Skip in responsive card view.
                if (self.isResponsiveMode()) {
                    return;
                }

                var field = $(this).data('sort-field');
                if (!field) {
                    if (typeof console !== 'undefined' && console.warn) {
                        console.warn('No sort field specified');
                    }
                    return;
                }

                // Auto-add field to columns if missing — preserves legacy behavior.
                if (self.config.columns.indexOf(field) === -1) {
                    self.config.columns.push(field);
                }

                var multiSortEnabled = !(self.config && self.config.enable_multi_sort === false);
                var isShift = !!(e && (e.shiftKey || (e.originalEvent && e.originalEvent.shiftKey)));

                var next = self.computeNextSortState({
                    stack: self.sortStack,
                    field: field,
                    isShift: isShift,
                    multiSortEnabled: multiSortEnabled,
                    currentField: self.sortField,
                    currentOrder: self.sortOrder,
                });

                if (next.error) { return; }

                self.sortStack = next.stack;
                self.sortField = next.field;
                self.sortOrder = next.order;
                self.loadEntries();

                // UI update: clear all + reapply per stack entry.
                $wrapper.find('.gt-sort-indicator').removeClass('active');
                $wrapper.find('.gt-sort-order-badge').remove();
                $wrapper.find('th[data-sort-field]').attr('aria-sort', 'none');
                for (var s = 0; s < self.sortStack.length; s++) {
                    var entry = self.sortStack[s];
                    var $th = $wrapper.find('th[data-sort-field="' + entry.column_id + '"]');
                    if (!$th.length) continue;
                    $th.find('.gt-sort-indicator').addClass('active');
                    $th.attr('aria-sort', entry.direction === 'asc' ? 'ascending' : 'descending');
                    if (self.sortStack.length > 1) {
                        $th.find('.gt-sort-arrows').first().append(
                            '<span class="gt-sort-order-badge" aria-hidden="true">' + (s + 1) + '</span>'
                        );
                    }
                }
            });
        }

    });

})(window);

/* ===== assets/js/frontend/filter-apply.js ===== */
/**
 * TableCrafter — frontend/filter-apply.js
 *
 * applyFilters + clearFilters — the orchestrator pair for the
 * advanced-filter pipeline. #834 slice 4 of N.
 *
 * Surface (attached to GravityTable.prototype via Object.assign):
 *
 *   - applyFilters()
 *       Walks every .gt-filter-field inside the wrapper, dispatches
 *       per data-field-type:
 *         - date: single-input OR from/to-range, both formatted via
 *           self.convertHtml5DateToFormat. Empty fields skipped.
 *         - number / range: gt-range-min + gt-range-max → number_range.
 *         - dropdown: single-select OR multi-select. Lookup variants
 *           detected via .gt-lookup-filter class.
 *         - checkboxes: collects checked .gt-checkbox-filter values
 *           into a values array.
 *         - default (text): .gt-filter-input value → text filter, or
 *           lookup filter if .gt-lookup-filter class is present.
 *       Each per-field branch is wrapped in try/catch so a malformed
 *       field doesn't abort processing of the others. Sets
 *       self.filters, resets self.currentPage to 1, persists state,
 *       reloads entries.
 *
 *   - clearFilters()
 *       Resets every filter input (.gt-filter-input + date html5 +
 *       date display + search input), wipes self.filters + searchTerm,
 *       resets currentPage to 1, clears persisted state, reloads.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery || window.$;

    Object.assign(window.GravityTable.prototype, {

        applyFilters: function () {
            var self = this;
            var $wrapper = $('#' + this.wrapperId);
            var filters = {};

            $wrapper.find('.gt-filter-field').each(function () {
                var $field = $(this);
                var fieldId = $field.data('field-id');
                var fieldType = $field.data('field-type');

                try {
                    switch (fieldType) {
                        case 'date': {
                            var $singleHtml5 = $field.find('.gt-date-html5:not(.gt-date-from-html5):not(.gt-date-to-html5)');
                            var $fromHtml5 = $field.find('.gt-date-from-html5');
                            var $toHtml5 = $field.find('.gt-date-to-html5');
                            var fromValue = '';
                            var toValue = '';
                            var dateFormat = (self.config && self.config.date_format) ? self.config.date_format : 'm/d/Y';

                            if ($singleHtml5.length > 0) {
                                if ($singleHtml5.val()) {
                                    fromValue = self.convertHtml5DateToFormat($singleHtml5.val(), dateFormat);
                                    toValue = fromValue;
                                }
                            } else {
                                if ($fromHtml5.val()) {
                                    fromValue = self.convertHtml5DateToFormat($fromHtml5.val(), dateFormat);
                                }
                                if ($toHtml5.val()) {
                                    toValue = self.convertHtml5DateToFormat($toHtml5.val(), dateFormat);
                                }
                            }

                            if (fromValue || toValue) {
                                filters['filter_' + fieldId] = {
                                    type: 'date_range',
                                    from: fromValue || '',
                                    to: toValue || ''
                                };
                            }
                            break;
                        }

                        case 'number':
                        case 'range': {
                            var minValue = $field.find('.gt-range-min').val();
                            var maxValue = $field.find('.gt-range-max').val();
                            if (minValue || maxValue) {
                                filters['filter_' + fieldId] = {
                                    type: 'number_range',
                                    min: minValue || '',
                                    max: maxValue || ''
                                };
                            }
                            break;
                        }

                        case 'dropdown': {
                            var $select = $field.find('.gt-dropdown-filter, .gt-lookup-filter');
                            if ($select.length && $select.val()) {
                                if ($select.prop('multiple')) {
                                    var selectedValues = $select.val();
                                    if (selectedValues && selectedValues.length > 0) {
                                        filters['filter_' + fieldId] = {
                                            type: 'dropdown',
                                            values: selectedValues
                                        };
                                    }
                                } else {
                                    var selectedValue = $select.val();
                                    if (selectedValue) {
                                        if ($select.hasClass('gt-lookup-filter')) {
                                            filters['filter_' + fieldId] = {
                                                type: 'lookup',
                                                value: selectedValue
                                            };
                                        } else {
                                            filters['filter_' + fieldId] = {
                                                type: 'dropdown',
                                                value: selectedValue
                                            };
                                        }
                                    }
                                }
                            }
                            break;
                        }

                        case 'checkboxes': {
                            var $checkboxes = $field.find('.gt-checkbox-filter:checked');
                            if ($checkboxes.length) {
                                var values = [];
                                $checkboxes.each(function () {
                                    values.push($(this).val());
                                });
                                if (values.length) {
                                    filters['filter_' + fieldId] = {
                                        type: 'checkboxes',
                                        values: values
                                    };
                                }
                            }
                            break;
                        }

                        default: {
                            var $input = $field.find('.gt-filter-input');
                            var value = $input.val();
                            if (value) {
                                if ($input.hasClass('gt-lookup-filter')) {
                                    filters['filter_' + fieldId] = {
                                        type: 'lookup',
                                        value: value
                                    };
                                } else {
                                    filters['filter_' + fieldId] = {
                                        type: 'text',
                                        value: value
                                    };
                                }
                            }
                            break;
                        }
                    }
                } catch (error) {
                    /* c8 ignore next */
                    if (typeof console !== 'undefined' && console.error) {
                        /* c8 ignore next */
                        console.error('GT Frontend: Error processing filter field', fieldId, ':', error);
                    }
                }
            });

            this.filters = filters;
            this.currentPage = 1;
            this.persistFilterStateLocal();
            this.loadEntries();
        },

        clearFilters: function () {
            var $wrapper = $('#' + this.wrapperId);

            $wrapper.find('.gt-filter-input').val('');
            $wrapper.find('.gt-date-display').val('');
            $wrapper.find('.gt-date-html5').val('');
            $wrapper.find('.gt-per-col-filter').val('');

            this.filters = {};
            this.searchTerm = '';
            $wrapper.find('.gt-search-input').val('');
            this.currentPage = 1;
            this.clearFilterStateLocal();
            this.loadEntries();
        },

        // #1679 — per-column filter row (.gt-per-col-filter inputs under the
        // header). Each input filters its own column via the existing server
        // text-filter path (filter_<fieldId> => {type:'text', value}). Merges
        // into this.filters without clobbering advanced-panel filters; an
        // emptied input removes only its own key. Wired (debounced) from
        // bindEvents().
        applyPerColumnFilters: function () {
            var self = this;
            var $wrapper = $('#' + this.wrapperId);

            if (!this.filters || typeof this.filters !== 'object') {
                this.filters = {};
            }

            $wrapper.find('.gt-per-col-filter').each(function () {
                var fieldId = $(this).attr('data-field');
                if (!fieldId) return;
                var key = 'filter_' + fieldId;
                var value = $(this).val();
                if (value !== null && value !== undefined && String(value).trim() !== '') {
                    // #1681 — lookup columns store an ID but display a name, so
                    // a typed name must be resolved server-side (lookup_name);
                    // plain columns use a straight text LIKE.
                    var isLookup = $(this).attr('data-lookup') === '1' || $(this).data('lookup') === 1;
                    self.filters[key] = { type: isLookup ? 'lookup_name' : 'text', value: value };
                } else {
                    delete self.filters[key];
                }
            });

            this.currentPage = 1;
            if (typeof this.persistFilterStateLocal === 'function') {
                this.persistFilterStateLocal();
            }
            this.loadEntries();
        }

    });

})(window);

/* ===== assets/js/frontend/typeahead.js ===== */
/**
 * TableCrafter — frontend/typeahead.js
 *
 * Text-filter typeahead. #834 slice 5 of 5 — closes #834.
 *
 * Surface (attached to GravityTable.prototype via Object.assign):
 *
 *   - initTextFilterTypeaheads($wrapper)
 *       Wires each `input.gt-text-filter` inside the wrapper as a
 *       typeahead. Idempotent via a `gtTypeaheadInit` data flag —
 *       safe to call after re-render.
 *
 *       Per input:
 *         - Wraps in `.gt-filter-typeahead-wrap` and appends a
 *           `.gt-filter-typeahead-suggest` <ul role="listbox">.
 *         - input event: debounced (200ms) AJAX to
 *           `gt_get_filter_suggestions` with the column's field_id.
 *         - focus event: immediate refresh (no debounce).
 *         - blur event: 200ms-delayed hide (so a suggestion click
 *           can fire before the list disappears).
 *         - keydown: ArrowDown/Up cycles the active item,
 *           Enter activates it (sets input value + calls
 *           applyFilters), Escape hides the list.
 *
 *       Bails when ajax_url / nonce / form_id are missing from config.
 *
 * Closes #834 (filter-sort umbrella).
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery || window.$;

    Object.assign(window.GravityTable.prototype, {

        initTextFilterTypeaheads: function ($wrapper) {
            var self = this;
            var ajaxUrl = (this.config && this.config.ajax_url) || (window.gtTableData && window.gtTableData[this.wrapperId] && window.gtTableData[this.wrapperId].ajax_url);
            var nonce = (this.config && this.config.nonce) || (window.gtTableData && window.gtTableData[this.wrapperId] && window.gtTableData[this.wrapperId].nonce);
            var formId = (this.config && this.config.form_id) || (window.gtTableData && window.gtTableData[this.wrapperId] && window.gtTableData[this.wrapperId].form_id);
            if (!ajaxUrl || !nonce || !formId) { return; }

            $wrapper.find('input.gt-text-filter').each(function () {
                var $input = $(this);
                if ($input.data('gtTypeaheadInit')) { return; }

                var $field = $input.closest('[data-field-id]');
                var fieldId = $field.attr('data-field-id');
                if (!fieldId) { return; }

                $input.data('gtTypeaheadInit', true);

                // Wrap and append the suggest list.
                $input.wrap('<div class="gt-filter-typeahead-wrap" style="position:relative;width:100%;" />');
                var $wrap = $input.parent();
                var $list = $('<ul class="gt-filter-typeahead-suggest" role="listbox" style="display:none;position:absolute;left:0;right:0;top:100%;z-index:10050;max-height:240px;overflow-y:auto;margin:2px 0 0;padding:0;list-style:none;background:#fff;border:1px solid #c3c4c7;border-radius:4px;box-shadow:0 6px 18px rgba(0,0,0,0.12);" />');
                $wrap.append($list);

                var activeIdx = -1;
                var inflight = null;
                var debounceT;

                function renderResults(results) {
                    $list.empty();
                    activeIdx = -1;
                    if (!results || !results.length) { $list.hide(); return; }
                    results.forEach(function (row, i) {
                        var label = (row && (row.label || row.value)) || '';
                        var $li = $('<li role="option" />').text(label).css({
                            padding: '8px 12px',
                            cursor: 'pointer',
                            borderBottom: '1px solid #f0f0f1',
                            color: '#1d2327'
                        });
                        $li.on('mousedown', function (e) { e.preventDefault(); });
                        $li.on('click', function () {
                            $input.val(label);
                            $list.hide().empty();
                            self.applyFilters();
                        });
                        $li.on('mouseenter', function () { activeIdx = i; refreshActive(); });
                        $list.append($li);
                    });
                    $list.show();
                }

                function refreshActive() {
                    // #1049 Option 2 v4.221.0 — vanilla DOM. Fires on every
                    // arrow-key press in a typeahead; was $list.children('li')
                    // jQuery selector parse + .each() + $(this).css() per
                    // item. Native: children NodeList + el.style.background.
                    var lis = $list[0].children;
                    for (var i = 0, n = lis.length; i < n; i++) {
                        lis[i].style.background = (i === activeIdx) ? '#f0f6fc' : 'transparent';
                    }
                }

                function fetchSuggestions(q) {
                    if (inflight) { inflight.abort(); }
                    inflight = $.ajax({
                        url: ajaxUrl,
                        method: 'POST',
                        dataType: 'json',
                        data: {
                            action: 'gt_get_filter_suggestions',
                            nonce: nonce,
                            form_id: formId,
                            field_id: fieldId,
                            q: q,
                            limit: 50
                        }
                    }).done(function (resp) {
                        var results = (resp && resp.success && resp.data && resp.data.results) ? resp.data.results : [];
                        renderResults(results);
                    }).fail(function (xhr) {
                        /* c8 ignore next */
                        if (xhr && xhr.statusText !== 'abort') { $list.hide(); }
                    });
                }

                $input.on('input.gtTypeahead', function () {
                    var q = String($input.val() || '').trim();
                    clearTimeout(debounceT);
                    debounceT = setTimeout(function () { fetchSuggestions(q); }, 200);
                });

                $input.on('focus.gtTypeahead', function () {
                    fetchSuggestions(String($input.val() || '').trim());
                });

                $input.on('blur.gtTypeahead', function () {
                    // Delay so click on a suggestion can fire first.
                    setTimeout(function () { $list.hide(); }, 200);
                });

                $input.on('keydown.gtTypeahead', function (e) {
                    var $items = $list.children('li');
                    if (e.key === 'ArrowDown' && $items.length) {
                        e.preventDefault();
                        activeIdx = (activeIdx + 1) % $items.length;
                        refreshActive();
                        $list.show();
                    } else if (e.key === 'ArrowUp' && $items.length) {
                        e.preventDefault();
                        activeIdx = activeIdx <= 0 ? $items.length - 1 : activeIdx - 1;
                        refreshActive();
                    } else if (e.key === 'Enter') {
                        if (activeIdx >= 0 && $items.eq(activeIdx).length) {
                            e.preventDefault();
                            $items.eq(activeIdx).trigger('click');
                        }
                        // Otherwise fall through to GT's existing keypress→applyFilters handler.
                    } else if (e.key === 'Escape') {
                        $list.hide().empty();
                    }
                });
            });
        }

    });

})(window);

/* ===== assets/js/frontend/totals.js ===== */
/**
 * TableCrafter — frontend/totals.js
 *
 * Column-totals row renderer. #832 slice 1 of N.
 *
 * Surface (attached to GravityTable.prototype via Object.assign):
 *
 *   - computeColumnTotal(cellValues, aggOverride, isNumeric)
 *       Pure helper. Given an array of raw cell text values, an
 *       optional aggregation override (SUM / AVG / MIN / MAX /
 *       COUNT / COUNT_DISTINCT) and a numeric-column flag, returns
 *       the computed value (Number or Integer) or null.
 *       - aggOverride wins. Mirrors TC_Formula_Service::compute_aggregation
 *         server-side switch.
 *       - When no override but isNumeric is true, falls back to the
 *         legacy auto-Sum-for-numeric behavior.
 *       - When neither applies, returns null (cell stays empty).
 *       - Numeric coercion: parseFloat on stripped digits ($, commas,
 *         non-digit prefixes are dropped before parseFloat).
 *
 *   - updateColumnTotals()
 *       DOM driver. Walks tbody, collects cell text per column,
 *       passes to computeColumnTotal, emits a <tr class="gt-totals-row">
 *       into tfoot.gt-column-totals. Formats:
 *         - COUNT / COUNT_DISTINCT → integer string.
 *         - total / calculation column type OR AVG override → 2 decimals.
 *         - Other numeric → integer if integral, otherwise 2 decimals.
 *       First non-numeric column gets the "Totals" label cell.
 *       Subsequent non-numeric columns get empty cells.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery || window.$;

    Object.assign(window.GravityTable.prototype, {

        computeColumnTotal: function (cellValues, aggOverride, isNumeric) {
            var self = this;
            // Shared numeric coercion (#1731): use the canonical
            // gtParseNumeric (util.js) so the column-totals row and the
            // Data Bars fill can never disagree. Falls back to the inline
            // form only if util.js isn't on the prototype (defensive —
            // never happens in production where util enqueues first).
            var coerce = (typeof self.gtParseNumeric === 'function')
                ? function (s) { return self.gtParseNumeric(s); }
                : function (s) { var v = parseFloat(String(s).replace(/[^0-9.\-]/g, '')); return isNaN(v) ? null : v; };
            var numeric = cellValues.map(coerce).filter(function (v) { return v !== null; });

            if (aggOverride) {
                switch (aggOverride) {
                    case 'SUM':
                        return numeric.reduce(function (a, b) { return a + b; }, 0);
                    case 'AVG':
                        return numeric.length ? numeric.reduce(function (a, b) { return a + b; }, 0) / numeric.length : 0;
                    case 'MIN':
                        return numeric.length ? Math.min.apply(null, numeric) : 0;
                    case 'MAX':
                        return numeric.length ? Math.max.apply(null, numeric) : 0;
                    case 'COUNT':
                        return cellValues.length;
                    case 'COUNT_DISTINCT':
                        return (new Set(cellValues)).size;
                    default:
                        return null;
                }
            }
            if (isNumeric) {
                return numeric.reduce(function (a, b) { return a + b; }, 0);
            }
            return null;
        },

        updateColumnTotals: function () {
            var self = this;
            var $wrapper = $('#' + this.wrapperId);
            var $tfoot = $wrapper.find('tfoot.gt-column-totals');
            if (!$tfoot.length) return;

            var html = '<tr class="gt-totals-row">';
            var firstTextColumn = true;

            if ($wrapper.find('.gt-selection-header').length > 0) {
                html += '<td class="gt-checkbox-cell"></td>';
            }

            var columnAggregations = (self.config && self.config.column_aggregations) || {};

            $.each(this.config.columns, function (colIndex, fieldId) {
                var colConfig = self.config.column_config[fieldId];
                var colType = colConfig ? colConfig.type : 'text';
                var isNumeric = (colType === 'number' || colType === 'quantity' || colType === 'total' || colType === 'calculation');
                var aggOverride = columnAggregations[fieldId] || '';

                var cellValues = [];
                if (aggOverride || isNumeric) {
                    $wrapper.find('tbody tr').each(function () {
                        var $cell = $(this).find('td[data-field-id="' + fieldId + '"]');
                        if ($cell.length) cellValues.push($cell.text());
                    });
                }

                var result = self.computeColumnTotal(cellValues, aggOverride, isNumeric);

                if (result !== null) {
                    var formatted;
                    if (typeof result === 'number') {
                        formatted = (aggOverride === 'COUNT' || aggOverride === 'COUNT_DISTINCT')
                            ? String(result)
                            : (colType === 'total' || colType === 'calculation' || aggOverride === 'AVG')
                                ? result.toFixed(2)
                                : (result % 1 === 0 ? result.toString() : result.toFixed(2));
                    } else {
                        /* c8 ignore next */
                        formatted = String(result);
                    }
                    html += '<td class="gt-totals-cell gt-totals-numeric" data-field-id="' + fieldId + '"><strong>' + formatted + '</strong></td>';
                    firstTextColumn = false;
                } else if (firstTextColumn) {
                    html += '<td class="gt-totals-cell gt-totals-label" data-field-id="' + fieldId + '"><strong>Totals</strong></td>';
                    firstTextColumn = false;
                } else {
                    html += '<td class="gt-totals-cell" data-field-id="' + fieldId + '"></td>';
                }
            });

            if ($wrapper.find('.gt-actions-header').length > 0) {
                /* c8 ignore next */
                html += '<td class="gt-totals-cell"></td>';
            }

            html += '</tr>';
            $tfoot.html(html);
        }

    });

})(window);

/* ===== assets/js/frontend/data-bars.js ===== */
/**
 * TableCrafter — frontend/data-bars.js
 *
 * Data Bars (#1731): Pro-gated, per-column, CSS-only in-cell horizontal
 * value bars for numeric columns.
 *
 * Design: the bar is emitted ONLY as a `data-gt-bar-pct` attribute plus
 * the `--gt-bar-pct` / `--gt-bar-color` CSS custom properties on the
 * existing <td>, driving a low-opacity `::after` underlay (see
 * frontend.css). NO markup is ever injected inside the cell, so the
 * column-totals row (totals.js), CSV/Excel/PDF export, conditional
 * formatting, inline editing, and card-view labels all keep reading the
 * unchanged bare number.
 *
 * Two pure helpers attached to GravityTable.prototype via Object.assign
 * (idiomatic with resolveCellEditability / computeColumnTotal):
 *
 *   - computeBarMaxes(entries, config)
 *       Page-scoped per-column max over the in-memory entries array.
 *       Returns { fieldId: maxNumber } only for enabled, numeric-type
 *       columns with a positive domain. Returns {} (no bars) for
 *       server-side processing, since a page-scoped max is meaningless
 *       there. Reuses the shared gtParseNumeric coercion.
 *
 *   - computeDataBarParams(config, fieldId, colCfg, rawValue, ctx)
 *       Per-cell gate + clamp. Returns { pct (0-100 int), color } or
 *       null. ctx = { barMax, isFileUpload, hasLinkSettings, hasCellBg }.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    // Mirrors the numeric-column set used by totals.js (line 92) so a
    // bar and its column total always agree on what counts as numeric.
    var NUMERIC_TYPES = ['number', 'quantity', 'total', 'calculation'];
    var DEFAULT_COLOR = '#3b82f6';

    function isNumericType(colCfg) {
        return !!colCfg && NUMERIC_TYPES.indexOf(colCfg.type) !== -1;
    }

    Object.assign(window.GravityTable.prototype, {

        computeBarMaxes: function (entries, config) {
            var self = this;
            var out = {};
            config = config || {};
            var bars = config.column_data_bars;
            // No bars configured or no rows: nothing to scale against.
            // SSP guard: page-scoped max is wrong for server-side processing,
            // UNLESS the server has already supplied bar_column_maxes for this
            // response (#1733). In that case, render-entries.js will merge
            // bar_column_maxes into _barMax after computeBarMaxes returns, so
            // we still need to populate the map for any bars the server
            // didn't cover — but only when server_bar_max_available is set.
            if (!bars || !entries || !entries.length) {
                return out;
            }
            if (config.processing_mode === 'server' && !config.server_bar_max_available) {
                return out;
            }
            var columnConfig = config.column_config || {};
            // #1738 — sparkline series collector.
            var seriesMap = {};
            for (var fieldId in bars) {
                if (!Object.prototype.hasOwnProperty.call(bars, fieldId)) continue;
                if (!bars[fieldId] || !bars[fieldId].enabled) continue;
                if (!isNumericType(columnConfig[fieldId])) continue;
                var max = null;
                var sparkline = !!bars[fieldId].sparkline;
                var series = sparkline ? [] : null;
                for (var i = 0; i < entries.length; i++) {
                    var n = self.gtParseNumeric(entries[i][fieldId]);
                    if (n !== null && (max === null || n > max)) {
                        max = n;
                    }
                    if (sparkline && n !== null) {
                        series.push(n);
                    }
                }
                // Only a positive domain yields a meaningful fill.
                if (max !== null && max > 0) {
                    out[fieldId] = max;
                }
                // #1738 — persist sparkline series on the instance.
                if (sparkline && series && series.length) {
                    seriesMap[fieldId] = series;
                }
            }
            if (Object.keys(seriesMap).length) {
                self._barSeries = seriesMap;
            }
            return out;
        },

        /**
         * #1734 — Data Bars: pivot view support.
         *
         * Computes a page-scoped max for each aggregate pivot column so
         * renderPivotRowsHtml can scale in-cell bars against it.
         *
         * @param {Array}  pivotRows  Aggregated row objects (keys like `12_sum`).
         * @param {Array}  cols       pivot column descriptors from pivotColumns().
         * @param {Object} config     Table config (column_data_bars, column_config,
         *                            pivot_config).
         * @returns {Object}  { '<col>_<op>': maxNumber } for enabled numeric fields
         *                    with a positive domain. Returns {} when nothing qualifies.
         */
        computePivotBarMaxes: function (pivotRows, cols, config) {
            var self = this;
            var out = {};
            config = config || {};
            var bars = config.column_data_bars;
            if (!bars || !pivotRows || !pivotRows.length || !cols || !cols.length) {
                return out;
            }
            var columnConfig = config.column_config || {};
            var groupByKey = (config.pivot_config && String(config.pivot_config.group_by)) || null;

            for (var c = 0; c < cols.length; c++) {
                var col = cols[c];
                var pivotKey = col.key;
                // Skip the group-by column — it is a label, not a numeric aggregate.
                if (pivotKey === groupByKey) { continue; }

                // Derive the originating field ID from the pivot key format `<col>_<op>`.
                var underscoreIdx = pivotKey.lastIndexOf('_');
                if (underscoreIdx === -1) { continue; }
                var fieldId = pivotKey.slice(0, underscoreIdx);

                // Gate 1: bars must be enabled for this originating field.
                if (!bars[fieldId] || !bars[fieldId].enabled) { continue; }
                // Gate 2: originating field must be numeric.
                if (!isNumericType(columnConfig[fieldId])) { continue; }

                // Scan pivot rows for the maximum value in this pivot column.
                var max = null;
                for (var i = 0; i < pivotRows.length; i++) {
                    var row = pivotRows[i] || {};
                    var raw = Object.prototype.hasOwnProperty.call(row, pivotKey) ? row[pivotKey] : null;
                    if (raw === null || raw === undefined) { continue; }
                    var n = self.gtParseNumeric(String(raw));
                    if (n !== null && (max === null || n > max)) {
                        max = n;
                    }
                }
                // Only a positive domain yields a meaningful fill.
                if (max !== null && max > 0) {
                    out[pivotKey] = max;
                }
            }
            return out;
        },

        computeDataBarParams: function (config, fieldId, colCfg, rawValue, ctx) {
            config = config || {};
            ctx = ctx || {};
            var bars = config.column_data_bars && config.column_data_bars[fieldId];
            if (!bars || !bars.enabled) return null;
            // Structural suppressions (see #1731 out-of-scope list).
            // SSP mode is allowed when the caller supplies a server-scoped barMax
            // via ctx.barMax (set by renderSSPEntries from _sspBarMaxes — #1735).
            if (config.processing_mode === 'server' && !(ctx.barMax && ctx.barMax > 0)) return null;
            if (config.column_auto_merge && config.column_auto_merge[fieldId]) return null;
            if (!isNumericType(colCfg)) return null;
            if (ctx.isFileUpload || ctx.hasLinkSettings || ctx.hasCellBg) return null;
            // Value gate: empty / non-numeric / server-HTML passthrough.
            if (rawValue === null || rawValue === undefined || rawValue === '') return null;
            if (String(rawValue).charAt(0) === '<') return null;
            var max = ctx.barMax;
            if (!(typeof max === 'number' && max > 0)) return null;
            var v = this.gtParseNumeric(rawValue);
            if (v === null) return null;
            var pct = Math.round((v / max) * 100);
            if (pct < 0) pct = 0;
            if (pct > 100) pct = 100;
            var color = (bars.color && String(bars.color)) || DEFAULT_COLOR;

            // #1738 — visual sub-options.
            var gradient  = !!bars.gradient;
            var bipolar   = !!bars.bipolar;
            var result    = { pct: pct, color: color, gradient: gradient, bipolar: bipolar };

            // Bipolar: compute signed pct in range [-100, +100].
            if (bipolar) {
                var barMin    = (typeof ctx.barMin === 'number') ? ctx.barMin : 0;
                var signedPct = 0;
                if (v >= 0) {
                    signedPct = (max > 0) ? Math.round((v / max) * 100) : 0;
                } else {
                    signedPct = (barMin < 0) ? Math.round((v / Math.abs(barMin)) * 100) : 0;
                }
                if (signedPct > 100)  { signedPct = 100; }
                if (signedPct < -100) { signedPct = -100; }
                result.signedPct = signedPct;
            }

            return result;
        }

    });

})(window);

/* ===== assets/js/frontend/badge-cell.js ===== */
/**
 * #1741 — Status Badge Cell Type (Free).
 *
 * Post-render pass: walks td[data-field-id] cells for columns that have a
 * badge map configured, wraps matching values in a colored pill badge span.
 * Runs after JS-side entry rendering (initial inline load). Server-side
 * rendering (AJAX response) produces badge HTML directly via TC_Badge_Service.
 */
(function () {
    'use strict';

    window.GravityTable = window.GravityTable || function () {};

    Object.assign(window.GravityTable.prototype, {

        /**
         * Apply badge spans to all rendered cells that match a badge map entry.
         * Safe to call multiple times — skips cells that already have a .gt-badge.
         */
        applyBadges: function () {
            var config = this.config || {};
            var badgeMap = config.column_badge_map;
            if (!badgeMap || typeof badgeMap !== 'object') {
                return;
            }
            var fieldIds = Object.keys(badgeMap);
            if (!fieldIds.length) {
                return;
            }

            var self = this;
            fieldIds.forEach(function (fieldId) {
                var colMap = badgeMap[fieldId];
                if (!colMap || typeof colMap !== 'object') {
                    return;
                }
                var cells = document.querySelectorAll('td[data-field-id="' + fieldId + '"]');
                cells.forEach(function (td) {
                    if (td.querySelector('.gt-badge')) {
                        return;
                    }
                    var rawValue = td.textContent;
                    if (!Object.prototype.hasOwnProperty.call(colMap, rawValue)) {
                        return;
                    }
                    var entry = colMap[rawValue];
                    var bg    = entry.bg   || '#e5e7eb';
                    var text  = entry.text || '#111827';
                    var span  = document.createElement('span');
                    span.className = 'gt-badge';
                    span.style.background    = bg;
                    span.style.color         = text;
                    span.style.borderRadius  = '9999px';
                    span.style.padding       = '2px 10px';
                    span.style.fontSize      = '.8em';
                    span.style.fontWeight    = '600';
                    span.style.display       = 'inline-block';
                    span.style.whiteSpace    = 'nowrap';
                    span.textContent         = rawValue;
                    td.textContent           = '';
                    td.appendChild(span);
                });
            });
        },

    });

})();

/* ===== assets/js/frontend/auto-refresh.js ===== */
/**
 * TableCrafter — frontend/auto-refresh.js
 *
 * Configurable table auto-refresh on interval (#1743, Free).
 * Last-updated timestamp display (#1912, Free).
 *
 * Public surface (GravityTable.prototype):
 *
 *   startAutoRefresh()
 *     Reads this.config.auto_refresh_interval (seconds). Enforces a 5-second
 *     minimum. Skips the loadEntries call when an inline edit is in progress
 *     (.gt-editing is present in the DOM). Clears any existing timer first
 *     so a double-call never stacks timers. Calls updateLastRefreshed() after
 *     each successful reload.
 *
 *   stopAutoRefresh()
 *     Clears the interval and nulls _autoRefreshTimer. Safe to call multiple
 *     times or before startAutoRefresh has been called.
 *
 *   updateLastRefreshed()
 *     Stamps the .gt-last-updated element inside the current table wrapper
 *     with the time of the most recent refresh. Text is updated live by a
 *     secondary 30-second interval so the "X seconds ago" stays accurate.
 *
 * Called from init.js after table initialisation when
 * config.auto_refresh_interval > 0.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        window.GravityTable = function GravityTable() {};
    }

    var MIN_INTERVAL_MS = 5000; // 5 seconds floor

    GravityTable.prototype.startAutoRefresh = function () {
        var self = this;
        var intervalSec = parseInt(this.config && this.config.auto_refresh_interval, 10) || 0;
        if (intervalSec <= 0) { return; }

        var intervalMs = Math.max(intervalSec * 1000, MIN_INTERVAL_MS);

        // Clear any existing timer before setting a new one
        this.stopAutoRefresh();

        this._autoRefreshTimer = setInterval(function () {
            // Skip refresh while an inline edit is in progress
            if (document.querySelector('.gt-edit-input')) { return; }
            if (typeof self.loadEntries === 'function') {
                self.loadEntries();
                self.updateLastRefreshed();
            }
        }, intervalMs);
    };

    GravityTable.prototype.stopAutoRefresh = function () {
        if (this._autoRefreshTimer) {
            clearInterval(this._autoRefreshTimer);
            this._autoRefreshTimer = null;
        }
        if (this._lastUpdatedTimer) {
            clearInterval(this._lastUpdatedTimer);
            this._lastUpdatedTimer = null;
        }
    };

    GravityTable.prototype.updateLastRefreshed = function () {
        var self = this;
        var wrapperId = this.wrapperId;
        if (!wrapperId) { return; }

        var wrapper = document.getElementById(wrapperId);
        if (!wrapper) { return; }

        var el = wrapper.querySelector('.gt-last-updated');
        if (!el) { return; }

        var refreshedAt = new Date();

        function formatAgo(then) {
            var secs = Math.floor((new Date() - then) / 1000);
            if (secs < 60) { return secs + 's ago'; }
            var mins = Math.floor(secs / 60);
            return mins + 'm ago';
        }

        function render() {
            el.textContent = 'Last refreshed ' + formatAgo(refreshedAt);
        }

        render();

        // Clear any previous relative-time ticker for this instance
        if (self._lastUpdatedTimer) {
            clearInterval(self._lastUpdatedTimer);
        }
        // Update the "X seconds ago" text every 30 seconds
        self._lastUpdatedTimer = setInterval(render, 30000);
    };

})(window);

/* ===== assets/js/frontend/ssp.js ===== */
/**
 * TableCrafter — frontend/ssp.js
 *
 * Server-side processing entries pipeline. #832 slice 2 of N.
 *
 * Surface (attached to GravityTable.prototype via Object.assign):
 *
 *   - loadEntriesServerSide()
 *       AJAX to `gt_server_side_entries` with the
 *       DataTables-compatible draw/start/length envelope. Increments
 *       `self._ssDrawCounter` per call. Adds a loading row + class
 *       while in flight. On success: stores `totalEntries` +
 *       `totalFiltered`, calls `renderSSPEntries(response.data)`,
 *       calls `updatePagination(totalFiltered)`. On missing
 *       recordsTotal or AJAX failure: renders an inline error
 *       message into the tbody.
 *
 *   - renderSSPEntries(rows)
 *       Renders the SSP response payload. `rows` is an array of
 *       arrays of already-rendered cell strings (server-side
 *       formatted). Empty / null arrays render a "no entries"
 *       placeholder cell that spans every column.
 *       Null/undefined cell values render as empty string (avoids
 *       literal "null" / "undefined" leaking into the table).
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery || window.$;

    Object.assign(window.GravityTable.prototype, {

        loadEntriesServerSide: function () {
            var self = this;
            var $wrapper = $('#' + this.wrapperId);
            var $tbody = $wrapper.find('tbody');

            if (!this._ssDrawCounter) {
                this._ssDrawCounter = 0;
            }
            this._ssDrawCounter += 1;
            var draw = this._ssDrawCounter;

            var start  = (this.currentPage - 1) * this.config.per_page;
            var length = this.config.per_page;

            $wrapper.addClass('gt-table-loading');
            $tbody.html('<tr class="gt-loading-row"><td colspan="' + ($wrapper.find('thead th').length) + '"><div class="gt-loading">' + ((this.config && this.config.pagination_labels && this.config.pagination_labels.loading) || 'Loading entries...') + '</div></td></tr>');

            var data = {
                action: 'gt_server_side_entries',
                nonce:    this.config.nonce,
                table_id: this.config.table_id,
                'draw':   draw,
                'start':  start,
                'length': length,
                search: { value: this.searchTerm || '' },
                order: [{ column: 0, dir: this.sortOrder || 'desc' }]
            };

            $.extend(data, this.filters);

            $.post(this.config.ajax_url, data, function (response) {
                $wrapper.removeClass('gt-table-loading');
                if (response && typeof response.recordsTotal !== 'undefined') {
                    self.totalEntries = parseInt(response.recordsTotal, 10) || 0;
                    self.totalFiltered = parseInt(response.recordsFiltered, 10) || self.totalEntries;
                    // #1735 — store server-supplied per-column bar maxes so
                    // renderSSPEntries can scale bars against the full filtered set.
                    self._sspBarMaxes = (response.bar_maxes && typeof response.bar_maxes === 'object')
                        ? response.bar_maxes
                        : {};
                    self.renderSSPEntries(response.data || []);
                    self.updatePagination(self.totalFiltered);
                } else {
                    $tbody.html('<tr><td colspan="' + ($wrapper.find('thead th').length) + '"><div class="gt-error">Server-side load error</div></td></tr>');
                }
            }).fail(function () {
                $wrapper.removeClass('gt-table-loading');
                $tbody.html('<tr><td colspan="' + ($wrapper.find('thead th').length) + '"><div class="gt-error">Error loading entries</div></td></tr>');
            });
        },

        renderSSPEntries: function (rows) {
            var self = this;
            var $wrapper = $('#' + this.wrapperId);
            var $tbody = $wrapper.find('tbody');
            var html = '';
            if (!rows || rows.length === 0) {
                html = '<tr><td colspan="' + ($wrapper.find('thead th').length) + '" class="gt-no-entries">' + ((self.config.pagination_labels && self.config.pagination_labels.no_results) || self.config.no_entries_text || 'No entries found.') + '</td></tr>';
            } else {
                // #1735 — grab server-supplied bar maxes (set by loadEntriesServerSide).
                var barMaxes = (self._sspBarMaxes && typeof self._sspBarMaxes === 'object')
                    ? self._sspBarMaxes
                    : {};
                var columns = (self.config && self.config.columns) || [];
                for (var i = 0; i < rows.length; i++) {
                    html += '<tr>';
                    for (var j = 0; j < rows[i].length; j++) {
                        var cellVal = rows[i][j] !== null && rows[i][j] !== undefined ? rows[i][j] : '';
                        var tdAttrs = '';
                        // Emit bar attributes when the server supplied a max for this column.
                        var col = columns[j];
                        var fieldId = col ? (col.field_id !== undefined ? String(col.field_id) : null) : null;
                        if (
                            fieldId !== null &&
                            barMaxes[fieldId] > 0 &&
                            typeof self.computeDataBarParams === 'function'
                        ) {
                            var colCfg = self.config.column_config && self.config.column_config[fieldId];
                            var barParams = self.computeDataBarParams(
                                self.config,
                                fieldId,
                                colCfg,
                                cellVal,
                                { barMax: barMaxes[fieldId] }
                            );
                            if (barParams) {
                                tdAttrs = ' data-gt-bar-pct="' + barParams.pct + '"' +
                                          ' style="--gt-bar-pct:' + barParams.pct + ';--gt-bar-color:' + barParams.color + ';"';
                            }
                        }
                        html += '<td' + tdAttrs + '>' + cellVal + '</td>';
                    }
                    html += '</tr>';
                }
            }
            $tbody.html(html);
        }

    });

})(window);

/* ===== assets/js/frontend/row-link-resolver.js ===== */
/**
 * TableCrafter — frontend/row-link-resolver.js
 *
 * Row-link template resolver (#567). #832 slice 3 of N.
 *
 * Surface (attached to GravityTable.prototype via Object.assign):
 *
 *   - resolveRowLinkAttrs(template, entry)
 *       Pure helper. Resolves the per-row href from a row-link
 *       template (e.g. "/orders/{12}") plus an entry record. Returns
 *       the attribute string to splice into the <tr> opening tag.
 *
 *       Empty template / empty resolved href → returns ''.
 *       Otherwise returns:
 *         ' class="gt-row-clickable" data-row-href="<escaped>"
 *           role="link" tabindex="0" aria-label="Open entry [#<id>]"'
 *
 *       Token replacement supports `{<fieldId>}` where fieldId is
 *       any alphanumeric / underscore / dot string. Each replacement
 *       passes through encodeURIComponent. Missing fields render as
 *       empty.
 *
 *       aria-label uses entry_id when present (#567 slice 2.1); falls
 *       back to plain "Open entry" when entry_id is undefined — custom
 *       data sources like woocommerce_products and JSON/REST adapters
 *       don't expose a numeric entry_id, and screen readers should
 *       never announce "Open entry #undefined" (#567 slice 2.2).
 *
 *       Pre-requisite: util.js must load first (escapeHtml).
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    Object.assign(window.GravityTable.prototype, {

        resolveRowLinkAttrs: function (template, entry) {
            var self = this;
            if (!template) return '';
            var resolvedHref = String(template).replace(/\{([0-9a-zA-Z_.]+)\}/g, function (_, fieldId) {
                var v = entry[fieldId];
                return v == null ? '' : encodeURIComponent(String(v));
            });
            if (!resolvedHref) return '';

            var rowAriaLabel = entry.entry_id
                ? self.escapeHtml('Open entry #' + entry.entry_id)
                : self.escapeHtml('Open entry');
            return ' class="gt-row-clickable" data-row-href="'
                + self.escapeHtml(resolvedHref)
                + '" role="link" tabindex="0"'
                + ' aria-label="' + rowAriaLabel + '"';
        }

    });

})(window);

/* ===== assets/js/frontend/alignment-resolver.js ===== */
/**
 * TableCrafter — frontend/alignment-resolver.js
 *
 * Cell alignment style + class builder (#549). #832 slice 4 of N.
 *
 * Surface (attached to GravityTable.prototype via Object.assign):
 *
 *   - resolveCellAlignment(config, fieldId, entryId)
 *       Pure helper. Returns { style, valignClass } given the table
 *       config + a (fieldId, entryId) lookup pair. Three layers
 *       stack (later wins) per #549 slice 3:
 *         1. column_alignments[fieldId] — horizontal text-align.
 *         2. column_vertical_alignments[fieldId] — vertical-align base.
 *         3. cell_vertical_alignments[entryId][fieldId] — per-cell
 *            v-align override.
 *
 *       Output shape:
 *         { style: ' style="text-align:X;vertical-align:Y"' | '',
 *           valignClass: ' gt-valign-X' | '' }
 *
 *       Empty `colVAlign` means: emit no vertical-align so the
 *       browser default of `middle` applies — preserves prior
 *       behavior. `middle` explicitly does NOT emit a class (also
 *       browser default).
 *
 *       Pure: no DOM, no jQuery, no `this` reads. Fully testable.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    Object.assign(window.GravityTable.prototype, {

        resolveCellAlignment: function (config, fieldId, entryId) {
            var colAlign = (config && config.column_alignments && config.column_alignments[fieldId])
                ? config.column_alignments[fieldId]
                : '';
            var colVAlign = (config && config.column_vertical_alignments && config.column_vertical_alignments[fieldId])
                ? config.column_vertical_alignments[fieldId]
                : '';
            var cellOverride = (config && config.cell_vertical_alignments && config.cell_vertical_alignments[entryId] && config.cell_vertical_alignments[entryId][fieldId])
                ? config.cell_vertical_alignments[entryId][fieldId]
                : null;
            var finalVAlign = cellOverride || colVAlign || '';

            var styleParts = [];
            if (colAlign) { styleParts.push('text-align:' + colAlign); }
            if (finalVAlign) { styleParts.push('vertical-align:' + finalVAlign); }
            var style = styleParts.length ? ' style="' + styleParts.join(';') + '"' : '';

            var valignClass = (finalVAlign && finalVAlign !== 'middle') ? ' gt-valign-' + finalVAlign : '';

            return { style: style, valignClass: valignClass };
        }

    });

})(window);

/* ===== assets/js/frontend/link-anchor.js ===== */
/**
 * TableCrafter — frontend/link-anchor.js
 *
 * Column link-settings anchor builder (#109). #832 slice 5 of N.
 *
 * Surface (attached to GravityTable.prototype via Object.assign):
 *
 *   - buildLinkAnchorHtml(linkSettings, displayValue)
 *       Pure helper. Given `column_link_settings` config (or null)
 *       and a `displayValue`, returns the `<a>` HTML or null when
 *       no link should be emitted. Output:
 *         <a href="<escaped>"[ target="X"[ rel="..."]][ style="..."]>
 *           <escaped displayValue>
 *         </a>
 *
 *       Mapping:
 *         link_target: '_self' (default) → no target attr.
 *                      'new_tab' → target="_blank" rel="noopener noreferrer".
 *                      Any other → target="<value>".
 *         link_color: '<css color>' → style="color:..."
 *         link_underline: false → style adds "text-decoration:none"
 *                          (default/undefined/true keeps browser underline).
 *
 *       Pre-requisite: util.js must load first (escapeHtml).
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    Object.assign(window.GravityTable.prototype, {

        buildLinkAnchorHtml: function (linkSettings, displayValue) {
            var self = this;
            if (!linkSettings || !displayValue) return null;

            var linkTarget = linkSettings.link_target || '_self';
            var linkColor = linkSettings.link_color || '';
            var linkUnderline = (linkSettings.link_underline !== false);

            var styleParts = [];
            if (linkColor) { styleParts.push('color:' + linkColor); }
            if (!linkUnderline) { styleParts.push('text-decoration:none'); }
            var styleAttr = styleParts.length ? ' style="' + styleParts.join(';') + '"' : '';

            var targetAttr = linkTarget && linkTarget !== '_self' ? ' target="' + linkTarget + '"' : '';
            if (linkTarget === 'new_tab') {
                targetAttr = ' target="_blank" rel="noopener noreferrer"';
            }

            return '<a href="' + self.escapeHtml(displayValue) + '"' + targetAttr + styleAttr + '>'
                + self.escapeHtml(displayValue) + '</a>';
        }

    });

})(window);

/* ===== assets/js/frontend/toggle-cell.js ===== */
/**
 * TableCrafter — frontend/toggle-cell.js
 *
 * Toggle / boolean column cell renderer (#325). #832 slice 6 of N.
 *
 * Surface (attached to GravityTable.prototype via Object.assign):
 *
 *   - renderToggleCellHtml({ value, fieldId, entryId, colConfig,
 *                            isEditable, valignClass, colAlignStyle })
 *       Pure helper. Builds a `<td class="gt-toggle-cell ...">` with a
 *       nested `<span class="gt-toggle-switch">` for a toggle column.
 *       Reads `normalizeToggleValue` + `escapeHtml` off `this` so it
 *       can stay pure (no DOM, no jQuery).
 *
 *       isEditable=true adds:
 *         - `gt-editable-cell` to the <td> class list (vs gt-readonly-cell).
 *         - `role="switch"`, `aria-checked`, `tabindex="0"` on the span.
 *
 *       colConfig.toggle_on_label / toggle_off_label override the
 *       default "Yes" / "No" aria-label. Both are escapeHtml-ed.
 *
 *       Pre-requisite: util.js must load first (normalizeToggleValue,
 *       escapeHtml).
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    Object.assign(window.GravityTable.prototype, {

        renderToggleCellHtml: function (opts) {
            var self = this;
            var value         = opts.value;
            var fieldId       = opts.fieldId;
            var entryId       = opts.entryId;
            var colConfig     = opts.colConfig || {};
            var isEditable    = !!opts.isEditable;
            var valignClass   = opts.valignClass || '';
            var colAlignStyle = opts.colAlignStyle || '';

            var toggleVal  = self.normalizeToggleValue(value);
            var onLabel    = colConfig.toggle_on_label  || 'Yes';
            var offLabel   = colConfig.toggle_off_label || 'No';
            var stateClass = toggleVal ? 'gt-toggle-on' : 'gt-toggle-off';
            var ariaLabel  = toggleVal ? onLabel : offLabel;

            var switchHtml = '<span class="gt-toggle-switch ' + stateClass + '" ' +
                'aria-label="' + self.escapeHtml(ariaLabel) + '" ' +
                'data-toggle-value="' + toggleVal + '" ' +
                'data-field-id="' + fieldId + '" ' +
                'data-entry-id="' + entryId + '" ' +
                (isEditable ? 'role="switch" aria-checked="' + (toggleVal ? 'true' : 'false') + '" tabindex="0"' : '') +
                '></span>';

            return '<td class="gt-toggle-cell ' + (isEditable ? 'gt-editable-cell' : 'gt-readonly-cell') + valignClass + '" ' +
                'data-field-id="' + fieldId + '" data-entry-id="' + entryId + '"' + colAlignStyle + '>' +
                switchHtml + '</td>';
        }

    });

})(window);

/* ===== assets/js/frontend/actions-cell.js ===== */
/**
 * TableCrafter — frontend/actions-cell.js
 *
 * Per-row actions-cell renderer. #832 slice 7 of N.
 *
 * Surface (attached to GravityTable.prototype via Object.assign):
 *
 *   - renderActionsCellHtml(entryId, config)
 *       Pure helper. Builds the `<td class="gt-actions-cell">` with
 *       the per-row action icons:
 *         - 👁 view (always)
 *         - 🗑 delete (when config.enable_delete is truthy)
 *         - 🕘 history (always)
 *         - 🛒 WooCommerce create (when config.woocommerce.active +
 *                                  config.woocommerce.mapping_ready)
 *
 *       Order is intentional (v4.7.61): primary actions (View, Delete)
 *       come first because they get the most use and need to be
 *       reachable when the column gets clipped on narrow viewports.
 *       Secondary actions (Edit history, WC create) trail at the end
 *       where being slightly less reachable is fine.
 *
 *       Pure: no DOM, no jQuery, no `this` reads. The DOM check
 *       `$wrapper.find('.gt-actions-header').length > 0` stays in
 *       frontend.js — this helper assumes the caller already decided
 *       to render an actions cell.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    Object.assign(window.GravityTable.prototype, {

        renderActionsCellHtml: function (entryId, config) {
            var html = '<td class="gt-actions-cell">';
            html += '<span class="gt-action gt-view-action" title="View Details" data-entry-id="' + entryId + '">👁</span>';
            if (config && config.enable_delete) {
                html += '<span class="gt-action gt-delete-action" title="Delete" data-entry-id="' + entryId + '">🗑</span>';
            }
            // #1747 — duplicate entry (Pro).
            if (config && config.is_pro && config.enable_duplicate) {
                html += '<span class="gt-action gt-duplicate-action" title="Duplicate row" data-entry-id="' + entryId + '">⧉</span>';
            }
            html += '<span class="gt-action gt-history-action" title="Edit history" data-entry-id="' + entryId + '">🕘</span>';
            if (config && config.woocommerce && config.woocommerce.active && config.woocommerce.mapping_ready) {
                html += '<span class="gt-action gt-wc-create-action" title="Create WooCommerce product" data-entry-id="' + entryId + '">🛒</span>';
            }
            html += '</td>';
            return html;
        }

    });

})(window);

/* ===== assets/js/frontend/search-highlight.js ===== */
/**
 * TableCrafter — frontend/search-highlight.js
 *
 * #1606 — search-term highlighting in matched cells. When a global
 * search term is active, matched substrings in cell text are wrapped
 * in <mark class="gt-search-hit"> so users can see WHERE the match
 * occurred instead of scanning every column.
 *
 * XSS discipline: escape-then-highlight. Both the cell text and the
 * term go through escapeHtml before matching, so the only raw HTML
 * this module ever introduces is the <mark> wrapper itself.
 *
 * Scope (v1, per the issue): literal case-insensitive matches from
 * the global search box, applied to plain-text cell content. Cells
 * whose server-rendered HTML contains markup (links, images) are left
 * untouched; fuzzy-only matches (no literal substring) simply don't
 * highlight. Card view + per-column filters are follow-ups.
 *
 * Surface (attached to GravityTable.prototype via Object.assign):
 *
 *   - highlightInEscapedHtml(escapedText, term)
 *       Wrap case-insensitive literal occurrences of `term` (escaped
 *       through this.escapeHtml so entity forms match) in already-
 *       escaped text. Returns the input unchanged for empty terms,
 *       no matches, or non-string input ('' for null/undefined text).
 *
 *   - renderHighlightedText(rawText, term)
 *       this.escapeHtml(rawText) then highlightInEscapedHtml.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    function escapeRegExp(s) {
        return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    }

    Object.assign(window.GravityTable.prototype, {

        highlightInEscapedHtml: function (escapedText, term) {
            if (escapedText === null || escapedText === undefined) {
                return '';
            }
            escapedText = String(escapedText);
            if (typeof term !== 'string' || term.trim() === '') {
                return escapedText;
            }
            var needle = this.escapeHtml(term.trim());
            if (needle === '') {
                return escapedText;
            }
            var re = new RegExp(escapeRegExp(needle), 'gi');
            return escapedText.replace(re, '<mark class="gt-search-hit">$&</mark>');
        },

        renderHighlightedText: function (rawText, term) {
            return this.highlightInEscapedHtml(this.escapeHtml(rawText), term);
        }

    });

})(window);

/* ===== assets/js/frontend/search-highlight-dom.js ===== */
/**
 * TableCrafter — frontend/search-highlight-dom.js
 *
 * #1612 — search-highlight v2: DOM text-node walker.
 *
 * v6.1.4 (#1606) highlighted plain-text cells at the string level and
 * deliberately skipped cells with server-rendered markup so the
 * wrapper could never land inside a tag or attribute. This module
 * closes the remaining surfaces with a structurally-safe DOM walk:
 * only TEXT NODES are split and wrapped, so tags and attributes are
 * untouchable by construction. Covers HTML cells, the responsive
 * card view (.gt-cards-container), and per-column text-filter terms
 * (scoped to their own column's cells).
 *
 * Surface (attached to GravityTable.prototype via Object.assign):
 *   - highlightTextNodes(rootEl, term)   — returns wrap count
 *   - applyDomSearchHighlight($wrapper)  — post-render pass
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, MARK: 1, TEXTAREA: 1, INPUT: 1, SELECT: 1 };

    function collectTextNodes(root) {
        var out = [];
        (function walk(node) {
            for (var child = node.firstChild; child; child = child.nextSibling) {
                if (child.nodeType === 3) {
                    out.push(child);
                } else if (child.nodeType === 1 && !SKIP_TAGS[child.tagName]
                    && !(child.tagName === 'MARK' || (child.classList && child.classList.contains('gt-search-hit')))) {
                    walk(child);
                }
            }
        }(root));
        return out;
    }

    Object.assign(window.GravityTable.prototype, {

        highlightTextNodes: function (rootEl, term) {
            if (!rootEl || typeof term !== 'string' || term.trim() === '') {
                return 0;
            }
            var needle = term.trim().toLowerCase();
            var doc = rootEl.ownerDocument || window.document;
            var count = 0;
            var nodes = collectTextNodes(rootEl);
            for (var i = 0; i < nodes.length; i++) {
                var node = nodes[i];
                // A node may be split as we wrap; loop within it until
                // no further match remains in the trailing remainder.
                while (node) {
                    var idx = node.nodeValue.toLowerCase().indexOf(needle);
                    if (idx === -1) { break; }
                    var matchNode = node.splitText(idx);
                    var rest = matchNode.splitText(term.trim().length);
                    var mark = doc.createElement('mark');
                    mark.className = 'gt-search-hit';
                    matchNode.parentNode.insertBefore(mark, matchNode);
                    mark.appendChild(matchNode);
                    count++;
                    node = rest;
                }
            }
            return count;
        },

        /**
         * Post-render highlight pass: the global search term over the
         * table body AND the responsive card view, then each active
         * per-column TEXT filter scoped to its own column's cells.
         */
        applyDomSearchHighlight: function ($wrapper) {
            var self = this;
            var globalTerm = typeof this.searchTerm === 'string' ? this.searchTerm.trim() : '';
            if (globalTerm) {
                $wrapper.find('tbody').each(function () {
                    self.highlightTextNodes(this, globalTerm);
                });
                $wrapper.find('.gt-cards-container').each(function () {
                    self.highlightTextNodes(this, globalTerm);
                });
            }
            var filters = this.filters && typeof this.filters === 'object' ? this.filters : {};
            for (var key in filters) {
                if (!Object.prototype.hasOwnProperty.call(filters, key)) { continue; }
                var m = /^filter_(.+)$/.exec(key);
                var f = filters[key];
                if (!m || !f || f.type !== 'text' || typeof f.value !== 'string' || f.value.trim() === '') {
                    continue;
                }
                var fieldId = m[1];
                var term = f.value.trim();
                $wrapper.find('td[data-field-id="' + fieldId + '"]').each(function () {
                    self.highlightTextNodes(this, term);
                });
            }
        }

    });

})(window);

/* ===== assets/js/frontend/text-cell.js ===== */
/**
 * TableCrafter — frontend/text-cell.js
 *
 * Default (non-toggle) text-cell renderer. #832 slice 8 of N.
 *
 * Surface (attached to GravityTable.prototype via Object.assign):
 *
 *   - renderTextCellHtml({ fieldId, entryId, displayValue, cellHtml,
 *                          isEditable, isFileUpload, colAlignStyle,
 *                          valignClass })
 *       Pure-ish helper. Depends on `this.renderFileUploadCell` and
 *       `this.escapeHtml`. Returns the `<td>...</td>` markup for the
 *       default (non-toggle) cell render path.
 *
 *       Branch table:
 *         - isFileUpload + displayValue: gt-readonly-cell with image
 *           or link HTML from renderFileUploadCell.
 *         - isEditable + displayValue:   gt-editable-cell with
 *           escapeHtml(displayValue).
 *         - isEditable + empty:          gt-editable-cell + gt-empty-
 *           field, no inner content (so the CSS `:empty` selector can
 *           apply the placeholder UX).
 *         - readonly + entry_id field:   gt-readonly-cell with
 *           <a class="gt-view-detail"> wrapper (#132).
 *         - readonly + other field:      gt-readonly-cell with cellHtml
 *           or `&nbsp;`.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    Object.assign(window.GravityTable.prototype, {

        renderTextCellHtml: function (opts) {
            var self = this;
            var fieldId       = opts.fieldId;
            var entryId       = opts.entryId;
            var displayValue  = opts.displayValue;
            var cellHtml      = opts.cellHtml;
            var isEditable    = !!opts.isEditable;
            var isFileUpload  = !!opts.isFileUpload;
            var colAlignStyle = opts.colAlignStyle || '';
            var valignClass   = opts.valignClass || '';

            // Data Bars (#1731). When the caller passes a clamped barPct,
            // MERGE the bar CSS custom properties into the cell's style
            // attribute (never a second style="...") and add the
            // data-gt-bar-pct hook. Bars are attribute-only — never inner
            // markup — so totals / export / conditional-format /
            // inline-edit all keep reading the unchanged cell text.
            var barAttr = '';
            var barLabelHtml = '';
            var barPct = (typeof opts.barPct === 'number' && isFinite(opts.barPct)) ? opts.barPct : null;
            if (barPct !== null) {
                var barColor = opts.barColor || '#3b82f6';
                var barVars = '--gt-bar-pct:' + barPct + ';--gt-bar-color:' + barColor + ';';
                if (colAlignStyle && colAlignStyle.indexOf('style="') !== -1) {
                    colAlignStyle = colAlignStyle.replace('style="', 'style="' + barVars);
                } else {
                    colAlignStyle = colAlignStyle + ' style="' + barVars + '"';
                }
                barAttr = ' data-gt-bar-pct="' + barPct + '"';
                // #1738 — show_label: render the cell value as a visible
                // <span class="gt-bar-label"> alongside the ::after underlay.
                if (opts.showBarLabel) {
                    barLabelHtml = '<span class="gt-bar-label">' + (displayValue ? self.escapeHtml(String(displayValue)) : '') + '</span>';
                }
            }

            // #1606 — active global search term highlights matched
            // substrings (escape-then-highlight; plain text only).
            var searchTerm = (typeof self.searchTerm === 'string' && typeof self.highlightInEscapedHtml === 'function')
                ? self.searchTerm.trim()
                : '';

            if (isFileUpload && displayValue) {
                var fileHtml = self.renderFileUploadCell(displayValue);
                return '<td class="gt-readonly-cell' + valignClass + '" data-field-id="' + fieldId + '" data-entry-id="' + entryId + '"' + barAttr + colAlignStyle + '>' + fileHtml + barLabelHtml + '</td>';
            }

            if (isEditable) {
                if (displayValue) {
                    var editableContent = self.escapeHtml(displayValue);
                    if (searchTerm) {
                        editableContent = self.highlightInEscapedHtml(editableContent, searchTerm);
                    }
                    return '<td class="gt-editable-cell' + valignClass + '" data-field-id="' + fieldId + '" data-entry-id="' + entryId + '"' + barAttr + colAlignStyle + '>' + editableContent + barLabelHtml + '</td>';
                }
                return '<td class="gt-editable-cell gt-empty-field' + valignClass + '" data-field-id="' + fieldId + '" data-entry-id="' + entryId + '"' + barAttr + colAlignStyle + '>' + barLabelHtml + '</td>';
            }

            // Readonly branch. Highlight only plain-text cell content —
            // server-rendered markup (links, images) is left untouched
            // so the wrapper can't land inside an attribute. (#1606)
            var isEntryId = fieldId === 'entry_id';
            var displayContent = cellHtml || '&nbsp;';
            if (searchTerm && cellHtml && String(cellHtml).indexOf('<') === -1) {
                displayContent = self.highlightInEscapedHtml(String(cellHtml), searchTerm);
            }
            if (isEntryId) {
                displayContent = '<a href="#" class="gt-view-detail" data-entry-id="' + entryId + '">' + displayContent + '</a>';
            }
            return '<td class="gt-readonly-cell' + valignClass + '" data-field-id="' + fieldId + '" data-entry-id="' + entryId + '"' + barAttr + colAlignStyle + '>' + displayContent + barLabelHtml + '</td>';
        }

    });

})(window);

/* ===== assets/js/frontend/detail-row.js ===== */
/**
 * TableCrafter — frontend/detail-row.js
 *
 * #556 expandable detail-rows render path. #832 slice 9 of N.
 *
 * Two helpers attached to GravityTable.prototype via Object.assign:
 *
 *   - renderDetailToggleCellHtml(entryId, detailRowId)
 *       Pure helper. Returns the `<td class="gt-detail-toggle-cell">`
 *       containing the chevron toggle button with aria-expanded="false",
 *       aria-controls pointing at the matching detail row, an
 *       aria-label, and a right-pointing chevron (&#9654;) glyph.
 *       Depends on `this.escapeHtml` for the aria-label.
 *
 *   - renderDetailRowHtml(entry, columns, detailMap, columnConfig,
 *                         detailRowId, detailColCount)
 *       Pure helper. Returns the hidden `<tr class="gt-detail-row">`
 *       sibling that renders all detail-only fields as a dl/dt/dd grid.
 *       Falls back to a `<div class="gt-detail-row-empty">` placeholder
 *       when no detail-flagged field has a value on this entry. Depends
 *       on `this.escapeHtml`.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    Object.assign(window.GravityTable.prototype, {

        renderDetailToggleCellHtml: function (entryId, detailRowId) {
            var self = this;
            return '<td class="gt-detail-toggle-cell">'
                + '<button type="button" class="gt-detail-toggle"'
                + ' aria-expanded="false"'
                + ' aria-controls="' + detailRowId + '"'
                + ' aria-label="' + self.escapeHtml('Show row details') + '"'
                + ' data-entry-id="' + entryId + '">'
                + '<span class="gt-detail-toggle-chevron" aria-hidden="true">&#9654;</span>'
                + '</button></td>';
        },

        renderDetailRowHtml: function (entry, columns, detailMap, columnConfig, detailRowId, detailColCount) {
            var self = this;
            var detailCellsHtml = '';
            var anyDetailValue = false;

            for (var i = 0; i < columns.length; i++) {
                var fid = columns[i];
                if (!detailMap[fid]) { continue; }
                var rawVal = entry[fid];
                if (rawVal === undefined || rawVal === null || rawVal === '') { continue; }
                anyDetailValue = true;
                var detailColCfg = (columnConfig && columnConfig[fid]) ? columnConfig[fid] : {};
                var detailLabel = detailColCfg.label || fid;
                var detailVal = String(rawVal);
                var detailValHtml = detailVal.charAt(0) === '<' ? detailVal : self.escapeHtml(detailVal);
                detailCellsHtml += '<div class="gt-detail-row-cell">'
                    + '<dt class="gt-detail-row-label">' + self.escapeHtml(detailLabel) + '</dt>'
                    + '<dd class="gt-detail-row-value">' + detailValHtml + '</dd>'
                    + '</div>';
            }

            if (!anyDetailValue) {
                detailCellsHtml = '<div class="gt-detail-row-empty">' + self.escapeHtml('No additional details for this row.') + '</div>';
            }

            return '<tr class="gt-detail-row" id="' + detailRowId + '" hidden>'
                + '<td colspan="' + detailColCount + '">'
                + '<dl class="gt-detail-row-grid">' + detailCellsHtml + '</dl>'
                + '</td></tr>';
        }

    });

})(window);

/* ===== assets/js/frontend/entry-cell.js ===== */
/**
 * TableCrafter — frontend/entry-cell.js
 *
 * Per-column cell dispatcher. #832 slice 10 of N.
 *
 * Two helpers attached to GravityTable.prototype via Object.assign:
 *
 *   - resolveCellEditability(config, fieldId)
 *       Pure helper. Returns the boolean isEditable verdict given
 *       the table config + a fieldId. Branch table:
 *         1. config.enable_frontend_editing === false        → false
 *         2. fieldId is auto-generated (entry_id, date_created,
 *            date_updated, is_starred, is_read, ip, source_url,
 *            user_id)                                         → false
 *         3. column_config[fieldId].editable === false        → false
 *         4. else                                              → true
 *
 *   - renderEntryCellHtml(entry, fieldId)
 *       Orchestrator. Returns the full <td>…</td> markup for one
 *       (entry, fieldId) pair by composing:
 *         - resolveCellEditability (this module)
 *         - formatDate (util)              when column is a date type
 *         - resolveCellAlignment (alignment-resolver)
 *         - escapeHtml (util)              for #132/#439-safe cellHtml
 *         - buildLinkAnchorHtml (link-anchor)  when readonly + linkSettings
 *         - renderToggleCellHtml (toggle-cell)  when column.type === 'toggle'
 *         - renderTextCellHtml (text-cell)     otherwise (default branch)
 *
 *       Reads this.config so the call site stays tight:
 *         html += self.renderEntryCellHtml(entry, fieldId);
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var AUTO_GENERATED_FIELDS = [
        'entry_id', 'date_created', 'date_updated',
        'is_starred', 'is_read', 'ip', 'source_url', 'user_id'
    ];

    Object.assign(window.GravityTable.prototype, {

        resolveCellEditability: function (config, fieldId) {
            if (!config || !config.enable_frontend_editing) {
                return false;
            }
            if (AUTO_GENERATED_FIELDS.indexOf(fieldId) !== -1) {
                return false;
            }
            var colCfg = config.column_config && config.column_config[fieldId];
            if (colCfg && colCfg.editable === false) {
                return false;
            }
            return true;
        },

        renderEntryCellHtml: function (entry, fieldId) {
            var self = this;
            var config = self.config || {};
            var columnConfig = config.column_config || {};

            var value = entry[fieldId] || '';
            var isEditable = self.resolveCellEditability(config, fieldId);

            // Format dates according to user settings.
            var displayValue = value;
            var colCfg = columnConfig[fieldId] || {};
            if (value && (colCfg.type === 'date' || fieldId === 'date_created')) {
                displayValue = self.formatDate(value, config.date_format);
            }

            // Three-layer alignment cascade (#549).
            var _gtAlign = self.resolveCellAlignment(config, fieldId, entry['entry_id']);
            var colAlignStyle = _gtAlign.style;
            var valignClass = _gtAlign.valignClass;

            // Build cellHtml: if the value starts with '<' it contains
            // server-sanitized HTML (e.g. <img> tags) — pass through raw
            // so images render (#439). Otherwise escape the plain-text
            // value to prevent double-escaping when link settings later
            // wrap displayValue in an <a> tag (#132).
            var cellHtml = displayValue
                ? (displayValue.charAt(0) === '<' ? displayValue : self.escapeHtml(displayValue))
                : '';

            // Column link-settings anchor builder (#109/#362/#384).
            var linkSettings = (config.column_link_settings && config.column_link_settings[fieldId])
                ? config.column_link_settings[fieldId]
                : null;
            if (linkSettings && displayValue && !isEditable) {
                var anchorHtml = self.buildLinkAnchorHtml(linkSettings, displayValue);
                if (anchorHtml) {
                    cellHtml = anchorHtml;
                }
            }

            // Toggle / boolean column (#325).
            if (colCfg.type === 'toggle') {
                return self.renderToggleCellHtml({
                    value: displayValue,
                    fieldId: fieldId,
                    entryId: entry.entry_id,
                    colConfig: colCfg,
                    isEditable: isEditable,
                    valignClass: valignClass,
                    colAlignStyle: colAlignStyle,
                });
            }

            // Default text-cell branch (#132 anchor wrapper preserved).
            var isFileUpload = colCfg.type === 'fileupload' || colCfg.upload_capable;

            // Data Bars (#1731). Derive the per-cell bar params from the
            // page-scoped column max (self._barMax, set in renderEntries)
            // and the suppression context. computeDataBarParams returns
            // null whenever a bar should not render (free tier strips the
            // config upstream, so this only fires for premium tables).
            // Conditional-format backgrounds are suppressed by a CSS guard.
            var barParams = (typeof self.computeDataBarParams === 'function')
                ? self.computeDataBarParams(config, fieldId, colCfg, value, {
                    barMax: self._barMax ? self._barMax[fieldId] : undefined,
                    isFileUpload: isFileUpload,
                    hasLinkSettings: !!linkSettings,
                })
                : null;

            return self.renderTextCellHtml({
                fieldId: fieldId,
                entryId: entry.entry_id,
                displayValue: displayValue,
                cellHtml: cellHtml,
                isEditable: isEditable,
                isFileUpload: isFileUpload,
                colAlignStyle: colAlignStyle,
                valignClass: valignClass,
                barPct: barParams ? barParams.pct : null,
                barColor: barParams ? barParams.color : null,
            });
        }

    });

})(window);

/* ===== assets/js/frontend/entry-row.js ===== */
/**
 * TableCrafter — frontend/entry-row.js
 *
 * Per-row HTML builder + no-entries fallback. #832 slice 11 of N.
 *
 * Two helpers attached to GravityTable.prototype via Object.assign:
 *
 *   - renderEntryRowHtml(entry, ctx)
 *       Pure-ish helper (no jQuery, no DOM access — all DOM probes
 *       are pre-resolved by the caller into the `ctx` flags object).
 *       Returns the `<tr>…</tr>` markup for one entry plus the
 *       optional hidden detail-row sibling.
 *
 *       ctx shape:
 *         {
 *           rowLinkTpl,          // #567 row-link template string (or '')
 *           hasSelectionHeader,  // emit gt-checkbox-cell when true
 *           hasDetailHeader,     // emit detail-toggle TD + detail-row sibling
 *           hasActionsHeader,    // emit actions cell
 *           detailColCount,      // colspan for the hidden detail row
 *         }
 *
 *   - renderNoEntriesRowHtml(colCount, noResultsLabel)
 *       Pure helper. Returns the
 *       `<tr><td colspan="N"><div class="gt-no-entries">…</div></td></tr>`
 *       fallback row used when data.entries is empty.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    Object.assign(window.GravityTable.prototype, {

        renderEntryRowHtml: function (entry, ctx) {
            var self = this;
            ctx = ctx || {};

            var rowExtraAttrs = self.resolveRowLinkAttrs(ctx.rowLinkTpl || '', entry);
            var html = '<tr data-entry-id="' + entry.entry_id + '"' + rowExtraAttrs + '>';

            // Selection checkbox.
            if (ctx.hasSelectionHeader) {
                html += '<td class="gt-checkbox-cell"><input type="checkbox" class="gt-entry-checkbox" value="' + entry.entry_id + '"></td>';
            }

            // #556 chevron toggle TD.
            var detailRowId = '';
            if (ctx.hasDetailHeader) {
                detailRowId = self.wrapperId + '-detail-' + entry.entry_id;
                html += self.renderDetailToggleCellHtml(entry.entry_id, detailRowId);
            }

            // Per-column cell dispatch (#832 slice 10).
            var columns = (self.config && self.config.columns) ? self.config.columns : [];
            for (var i = 0; i < columns.length; i++) {
                html += self.renderEntryCellHtml(entry, columns[i]);
            }

            // Actions cell (#832 slice 7).
            if (ctx.hasActionsHeader) {
                html += self.renderActionsCellHtml(entry.entry_id, self.config);
            }

            html += '</tr>';

            // Hidden detail-row sibling (#556 / #832 slice 9).
            if (ctx.hasDetailHeader) {
                var detailMap = (self.config && self.config.column_detail_only) ? self.config.column_detail_only : {};
                var columnConfig = (self.config && self.config.column_config) ? self.config.column_config : {};
                html += self.renderDetailRowHtml(entry, columns, detailMap, columnConfig, detailRowId, ctx.detailColCount);
            }

            return html;
        },

        renderNoEntriesRowHtml: function (colCount, noResultsLabel) {
            var label = noResultsLabel || 'No entries found';
            return '<tr><td colspan="' + colCount + '"><div class="gt-no-entries">' + label + '</div></td></tr>';
        }

    });

})(window);

/* ===== assets/js/frontend/pivot-view.js ===== */
/**
 * TableCrafter — frontend/pivot-view.js
 *
 * #1596 — frontend pivot view + visitor raw/pivot toggle.
 *
 * The #562 slices shipped TC_Pivot_Service, the builder pivot config,
 * and the AJAX path that replaces entries with aggregated rows and
 * sets is_pivot=true on the response. No frontend code consumed the
 * flag: aggregated rows carry keys like `12_sum` that match no
 * configured column, so pivot-enabled tables rendered the group
 * column and blanks. This module renders the pivot column layout
 * (group-by + one column per aggregate) and injects a toolbar toggle
 * so visitors can flip between the pivot summary and the raw rows
 * (loadEntries sends pivot_view=raw; the AJAX block honors it).
 *
 * Pivot rows are read-only: no inline editing, selection, detail
 * chevrons, or row actions (aggregated rows have no entry_id).
 *
 * Surface (attached to GravityTable.prototype via Object.assign):
 *   - isPivotResponse(data)
 *   - pivotColumns(pivotConfig, resolveLabel)
 *   - formatPivotValue(v)
 *   - renderPivotHeadHtml(cols)
 *   - renderPivotRowsHtml(rows, cols)
 *   - applyPivotView(data, $wrapper)
 *   - restoreRawHead($wrapper)
 *   - ensurePivotToggle($wrapper)
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var OP_LABELS = { sum: 'Sum', count: 'Count', avg: 'Avg', min: 'Min', max: 'Max' };

    Object.assign(window.GravityTable.prototype, {

        isPivotResponse: function (data) {
            return !!(data && data.is_pivot && data.pivot_config);
        },

        pivotColumns: function (pivotConfig, resolveLabel) {
            var resolve = function (id) {
                var label = typeof resolveLabel === 'function' ? resolveLabel(String(id)) : '';
                return (typeof label === 'string' && label !== '') ? label : String(id);
            };
            var cols = [{ key: String(pivotConfig.group_by), label: resolve(pivotConfig.group_by) }];
            var aggs = Array.isArray(pivotConfig.aggregates) ? pivotConfig.aggregates : [];
            for (var i = 0; i < aggs.length; i++) {
                var a = aggs[i];
                if (!a || !a.col || !a.op) { continue; }
                cols.push({
                    key: String(a.col) + '_' + String(a.op),
                    label: resolve(a.col) + ' (' + (OP_LABELS[a.op] || a.op) + ')'
                });
            }
            return cols;
        },

        formatPivotValue: function (v) {
            if (v === null || v === undefined) { return ''; }
            if (typeof v === 'number' && isFinite(v) && !Number.isInteger(v)) {
                return String(Math.round(v * 100) / 100);
            }
            return String(v);
        },

        renderPivotHeadHtml: function (cols) {
            var self = this;
            var parts = [];
            for (var i = 0; i < cols.length; i++) {
                parts.push('<th class="gt-pivot-th" data-pivot-key="' + self.escapeHtml(cols[i].key) + '">' + self.escapeHtml(cols[i].label) + '</th>');
            }
            return parts.join('');
        },

        /**
         * #1734 — pivot data-bar support.
         *
         * Optional third argument `barMaxes` is a map keyed by pivot column key
         * (e.g. `12_sum`) with the page-scoped numeric maximum, produced by
         * computePivotBarMaxes.  When a positive entry exists for a column the
         * <td> receives:
         *   data-gt-bar-pct="<0-100>"
         *   style="--gt-bar-pct:<N>;--gt-bar-color:#xxxxxx"
         * matching the same attribute surface as the regular data-bar path in
         * text-cell.js, so the existing ::after CSS underlay drives the fill
         * without any markup change inside the cell.
         */
        renderPivotRowsHtml: function (rows, cols, barMaxes) {
            var self = this;
            var DEFAULT_BAR_COLOR = '#3b82f6';
            var parts = [];
            barMaxes = barMaxes || {};
            for (var r = 0; r < rows.length; r++) {
                var row = rows[r] || {};
                parts.push('<tr class="gt-pivot-row">');
                for (var c = 0; c < cols.length; c++) {
                    var colKey = cols[c].key;
                    var raw = Object.prototype.hasOwnProperty.call(row, colKey) ? row[colKey] : null;
                    var displayText = self.escapeHtml(self.formatPivotValue(raw));

                    // Bar emission: only for aggregate columns that have a positive max.
                    var barAttr = '';
                    var barStyle = '';
                    var colMax = barMaxes[colKey];
                    if (typeof colMax === 'number' && colMax > 0 && raw !== null && raw !== undefined) {
                        var numVal = (typeof raw === 'number') ? raw
                            : (typeof self.gtParseNumeric === 'function' ? self.gtParseNumeric(String(raw)) : null);
                        if (numVal !== null) {
                            var pct = Math.round((numVal / colMax) * 100);
                            if (pct < 0) { pct = 0; }
                            if (pct > 100) { pct = 100; }
                            barAttr = ' data-gt-bar-pct="' + pct + '"';
                            barStyle = ' style="--gt-bar-pct:' + pct + ';--gt-bar-color:' + DEFAULT_BAR_COLOR + '"';
                        }
                    }

                    parts.push('<td class="gt-readonly-cell gt-pivot-cell"' + barAttr + barStyle + '>' + displayText + '</td>');
                }
                parts.push('</tr>');
            }
            return parts.join('');
        },

        /**
         * Swap the table into the pivot layout for an is_pivot
         * response: cache the raw thead once, render pivot head +
         * rows, latch the toggle into the toolbar.
         */
        applyPivotView: function (data, $wrapper) {
            var self = this;
            var $headRow = $wrapper.find('thead tr').first();
            if (typeof self._rawTheadRowHtml !== 'string') {
                self._rawTheadRowHtml = $headRow.html() || '';
            }
            // Resolve labels from the CACHED raw thead so they survive
            // the swap (th[data-field-id] carries the configured label).
            var probe = window.document.createElement('tr');
            probe.innerHTML = self._rawTheadRowHtml;
            var resolveLabel = function (fieldId) {
                var th = probe.querySelector('th[data-field-id="' + fieldId + '"]');
                if (!th) { return ''; }
                // First text chunk only — sort arrows / filter inputs
                // inside the th must not leak into the label.
                var txt = (th.textContent || '').trim();
                return txt.split('\n')[0].trim();
            };
            var cols = self.pivotColumns(data.pivot_config, resolveLabel);
            $headRow.html(self.renderPivotHeadHtml(cols));
            var $tbody = $wrapper.find('tbody');
            var rows = Array.isArray(data.entries) ? data.entries : [];
            // #1734 — compute per-pivot-column bar maxes before rendering rows
            // so each aggregate cell can scale its fill proportionally.
            // typeof guard keeps harnesses without data-bars.js on the old path.
            var barMaxes = (typeof self.computePivotBarMaxes === 'function')
                ? self.computePivotBarMaxes(rows, cols, self.config || {})
                : {};
            if ($tbody[0]) {
                $tbody[0].innerHTML = self.renderPivotRowsHtml(rows, cols, barMaxes);
            }
            self._pivotHeadActive = true;
            self._pivotConfigured = true;
            self.ensurePivotToggle($wrapper);
        },

        /**
         * Put the raw thead back when leaving pivot view (visitor
         * toggled to raw, or a non-pivot response arrived).
         */
        restoreRawHead: function ($wrapper) {
            if (this._pivotHeadActive && typeof this._rawTheadRowHtml === 'string') {
                $wrapper.find('thead tr').first().html(this._rawTheadRowHtml);
            }
            this._pivotHeadActive = false;
            if (this._pivotConfigured) {
                this.ensurePivotToggle($wrapper);
            }
        },

        /**
         * Inject (once) and label the visitor toggle. Clicking flips
         * this.pivotViewRaw and reloads entries; loadEntries sends
         * pivot_view=raw so the server skips aggregation.
         */
        ensurePivotToggle: function ($wrapper) {
            var self = this;
            var $controls = $wrapper.find('.gt-table-controls').first();
            if (!$controls.length) { return; }
            var $btn = $controls.find('.gt-pivot-toggle');
            if (!$btn.length) {
                var $ = window.jQuery || window.$;
                $btn = $('<button type="button" class="gt-pivot-toggle"></button>');
                $btn.on('click', function () {
                    self.pivotViewRaw = !self.pivotViewRaw;
                    self.currentPage = 1;
                    self.loadEntries();
                });
                $controls.append($btn);
            }
            $btn.text(self.pivotViewRaw ? 'View pivot summary' : 'View raw data');
        }

    });

})(window);

/* ===== assets/js/frontend/table-summary.js ===== */
/**
 * TableCrafter — frontend/table-summary.js
 *
 * #1601 slice A — table summary line. When the per-table
 * `show_table_summary` setting is on, fetch gt_ai_table_summary
 * (TC_AI_Table_Summarizer — rule-based slice-1 engine, no API key
 * required) once on init and render a one-line digest above the
 * table content: row count, date span, numeric min/max/avg, dominant
 * text values.
 *
 * Surface (attached to GravityTable.prototype via Object.assign):
 *   - initTableSummary()        — gate on config, fetch, render
 *   - renderTableSummary(data)  — escaped digest line (idempotent)
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    Object.assign(window.GravityTable.prototype, {

        initTableSummary: function () {
            var self = this;
            if (!(this.config && this.config.show_table_summary)) {
                return;
            }
            var $ = window.jQuery || window.$;
            $.post(this.config.ajax_url, {
                action: 'gt_ai_table_summary',
                nonce: this.config.nonce,
                table_id: this.config.table_id
            }, function (response) {
                if (response && response.success && response.data) {
                    self.renderTableSummary(response.data);
                }
            });
        },

        renderTableSummary: function (data) {
            var bullets = (data && Array.isArray(data.bullets)) ? data.bullets : [];
            if (!bullets.length) {
                return;
            }
            var $ = window.jQuery || window.$;
            var $wrapper = $('#' + this.wrapperId);
            var $container = $wrapper.find('.gt-table-container').first();
            if (!$container.length) {
                /* c8 ignore next 2 */
                return;
            }
            var esc = typeof this.escapeHtml === 'function'
                ? this.escapeHtml.bind(this)
                : function (s) { return String(s); };
            var parts = [];
            for (var i = 0; i < bullets.length; i++) {
                parts.push(esc(String(bullets[i])));
            }
            var text = parts.join(' &middot; ');
            if (data.truncated) {
                text += ' <span class="gt-table-summary-note">(first 1,000 rows)</span>';
            }
            $wrapper.find('.gt-table-summary').remove();
            var $controls = $container.find('.gt-table-controls').first();
            var html = '<div class="gt-table-summary" role="note">' + text + '</div>';
            if ($controls.length) {
                $controls.after(html);
            } else {
                /* c8 ignore next 2 */
                $container.prepend(html);
            }
        }

    });

})(window);

/* ===== assets/js/frontend/computed-sort.js ===== */
/**
 * TableCrafter — frontend/computed-sort.js
 *
 * #1621 — client-side sort for computed columns. Computed values
 * (gtc_*) have no DB column, so SQL ORDER BY can't see them; their
 * th carries .gt-client-sortable (instead of .gt-sortable) and this
 * module reorders the LOADED page's rows in the DOM. Numeric-aware:
 * formatted values ("1,234.50") parse; '#ERR' and non-numeric values
 * sink to the bottom in either direction.
 *
 * Surface (attached to GravityTable.prototype via Object.assign):
 *   - sortRenderedRowsByColumn($wrapper, fieldId, direction)
 *   - bindComputedSortEvents($wrapper)
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery || window.$;

    function numericValue(text) {
        var cleaned = String(text || '').trim().replace(/,/g, '');
        if (cleaned === '' || cleaned === '#ERR') { return null; }
        var n = parseFloat(cleaned);
        return isNaN(n) ? null : n;
    }

    Object.assign(window.GravityTable.prototype, {

        sortRenderedRowsByColumn: function ($wrapper, fieldId, direction) {
            var $tbody = $wrapper.find('tbody').first();
            var rows = $tbody.find('tr').toArray();
            var dir = direction === 'desc' ? -1 : 1;
            rows.sort(function (a, b) {
                var av = numericValue($(a).find('td[data-field-id="' + fieldId + '"]').text());
                var bv = numericValue($(b).find('td[data-field-id="' + fieldId + '"]').text());
                // Non-numeric (#ERR, text, missing) sinks to the bottom
                // in either direction.
                if (av === null && bv === null) { return 0; }
                if (av === null) { return 1; }
                if (bv === null) { return -1; }
                return (av - bv) * dir;
            });
            for (var i = 0; i < rows.length; i++) {
                $tbody.append(rows[i]);
            }
        },

        bindComputedSortEvents: function ($wrapper) {
            var self = this;
            $wrapper.on('click.gt-computed-sort', 'th.gt-client-sortable', function (e) {
                if ($(e.target).closest('.gt-resizer').length) {
                    return;
                }
                var $th = $(this);
                var field = $th.data('sort-field');
                if (!field) { return; }
                var current = $th.attr('aria-sort');
                var direction = current === 'ascending' ? 'desc' : 'asc';
                self.sortRenderedRowsByColumn($wrapper, String(field), direction);
                $wrapper.find('th.gt-client-sortable').attr('aria-sort', 'none')
                    .find('.gt-sort-indicator').removeClass('active');
                $th.attr('aria-sort', direction === 'asc' ? 'ascending' : 'descending');
                $th.find('.gt-sort-indicator').addClass('active');
            });
        }

    });

})(window);

/* ===== assets/js/frontend/responsive-visibility.js ===== */
/**
 * TableCrafter — frontend/responsive-visibility.js
 *
 * Pure visibility helpers for responsive card-view rendering.
 * #832 slice 12 of N.
 *
 * Five small helpers attached to GravityTable.prototype via Object.assign.
 * All read `this.config.responsive_settings` keyed by fieldId — when the
 * key is absent or the entire map is missing, default to visible.
 *
 *   - isFieldVisibleInCards(fieldId, isTabletView)
 *       Returns whether a field should render in the card view at the
 *       current breakpoint. Reads `settings.tablet_visible` when
 *       isTabletView is true, otherwise `settings.mobile_visible`.
 *       Both fall through to true unless explicitly === false.
 *
 *   - isFieldVisibleOnMobile(fieldId)
 *       Convenience: settings.mobile_visible !== false.
 *
 *   - isFieldVisibleOnTablet(fieldId)
 *       Convenience: settings.tablet_visible !== false.
 *
 *   - getMobileLabel(fieldId, defaultLabel)
 *       Returns the trimmed mobile label override when set + non-empty,
 *       otherwise falls back to defaultLabel.
 *
 *   - isFieldVisibleOnCurrentDevice(fieldId, isTabletView)
 *       Alias of isFieldVisibleInCards — same branch table, used at
 *       different call sites in card render. Pre-existing duplication.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    Object.assign(window.GravityTable.prototype, {

        isFieldVisibleInCards: function (fieldId, isTabletView) {
            if (!this.config.responsive_settings || !this.config.responsive_settings[fieldId]) {
                return true;
            }
            var settings = this.config.responsive_settings[fieldId];
            if (isTabletView) {
                return settings.tablet_visible !== false;
            }
            return settings.mobile_visible !== false;
        },

        isFieldVisibleOnMobile: function (fieldId) {
            if (!this.config.responsive_settings || !this.config.responsive_settings[fieldId]) {
                return true;
            }
            return this.config.responsive_settings[fieldId].mobile_visible !== false;
        },

        isFieldVisibleOnTablet: function (fieldId) {
            if (!this.config.responsive_settings || !this.config.responsive_settings[fieldId]) {
                return true;
            }
            return this.config.responsive_settings[fieldId].tablet_visible !== false;
        },

        getMobileLabel: function (fieldId, defaultLabel) {
            if (this.config.responsive_settings && this.config.responsive_settings[fieldId]) {
                var mobileLabel = this.config.responsive_settings[fieldId].mobile_label;
                if (mobileLabel && mobileLabel.trim()) {
                    return mobileLabel.trim();
                }
            }
            return defaultLabel;
        },

        isFieldVisibleOnCurrentDevice: function (fieldId, isTabletView) {
            if (!this.config.responsive_settings || !this.config.responsive_settings[fieldId]) {
                return true;
            }
            var settings = this.config.responsive_settings[fieldId];
            if (isTabletView) {
                return settings.tablet_visible !== false;
            }
            return settings.mobile_visible !== false;
        }

    });

})(window);

/* ===== assets/js/frontend/toolbar-export.js ===== */
/**
 * TableCrafter — frontend/toolbar-export.js
 *
 * Toolbar export helpers: copy-to-clipboard + CSV/Excel/PDF download.
 * #832 slice 13 of N (with getVisibleTableData added in slice 17).
 *
 * Seven helpers attached to GravityTable.prototype via Object.assign:
 *
 *   - getVisibleTableData()
 *       Scrapes the live table thead + visible tbody rows into a 2D
 *       array of cell text values. Strips sort arrows + inline input
 *       widgets from header cells, collapses internal whitespace in
 *       body cells. Used as the data source for all five export
 *       paths below.
 *
 *   - toolbarBuildCSV(rows)
 *       Pure helper. Takes a 2D array of cell values, returns the RFC
 *       4180-style CSV string. Cells containing `"`, `,`, `\n`, or `\r`
 *       are double-quoted with inner `"` doubled. Rows are joined with
 *       CRLF.
 *
 *   - toolbarTriggerDownload(content, filename, mimeType)
 *       Wraps `content` in a Blob, builds a temporary `<a download>`,
 *       triggers a click, then revokes the object URL on a 100ms delay.
 *
 *   - toolbarCopyToClipboard()
 *       Reads visible table data via `this.getVisibleTableData()`, joins
 *       each row with tabs (for spreadsheet paste), and writes to the
 *       clipboard via navigator.clipboard.writeText when available,
 *       falling back to a hidden textarea + execCommand('copy').
 *
 *   - toolbarDownloadCSV()
 *       Orchestrator: getVisibleTableData → toolbarBuildCSV →
 *       toolbarTriggerDownload as text/csv.
 *
 *   - toolbarDownloadExcel()
 *       Same as toolbarDownloadCSV but prepends a UTF-8 BOM (`﻿`)
 *       and serves the file as `.xls` with application/vnd.ms-excel.
 *
 *   - toolbarDownloadPDF()
 *       Routes through the existing #531 Print button when present so
 *       the user gets print-all-rows + afterprint-restore behavior.
 *       Falls back to window.print() when the Print button is
 *       suppressed via #521 show_print=false.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    Object.assign(window.GravityTable.prototype, {

        getVisibleTableData: function () {
            var $wrapper = $('#' + this.wrapperId);
            var rows = [];
            var headers = [];

            $wrapper.find('.gt-table thead th').each(function () {
                var text = $(this).clone().find('.gt-sort-arrows, input').remove().end().text().trim();
                headers.push(text);
            });
            if (headers.length) rows.push(headers);

            $wrapper.find('.gt-table tbody tr:visible').each(function () {
                var row = [];
                $(this).find('td').each(function () {
                    row.push($(this).text().trim().replace(/\s+/g, ' '));
                });
                if (row.length) rows.push(row);
            });

            return rows;
        },

        toolbarBuildCSV: function (rows) {
            return rows.map(function (r) {
                return r.map(function (cell) {
                    var s = String(cell).replace(/"/g, '""');
                    return /[",\n\r]/.test(s) ? '"' + s + '"' : s;
                }).join(',');
            }).join('\r\n');
        },

        toolbarTriggerDownload: function (content, filename, mimeType) {
            var blob = new Blob([content], { type: mimeType });
            var url = URL.createObjectURL(blob);
            var a = document.createElement('a');
            a.href = url;
            a.download = filename;
            a.style.display = 'none';
            document.body.appendChild(a);
            a.click();
            setTimeout(function () {
                document.body.removeChild(a);
                URL.revokeObjectURL(url);
            }, 100);
        },

        toolbarCopyToClipboard: function () {
            var rows = this.getVisibleTableData();
            var text = rows.map(function (r) { return r.join('\t'); }).join('\n');

            if (navigator.clipboard && navigator.clipboard.writeText) {
                navigator.clipboard.writeText(text);
            } else {
                var ta = document.createElement('textarea');
                ta.value = text;
                ta.style.position = 'fixed';
                ta.style.opacity = '0';
                document.body.appendChild(ta);
                ta.focus();
                ta.select();
                document.execCommand('copy');
                document.body.removeChild(ta);
            }
        },

        toolbarDownloadCSV: function () {
            var rows = this.getVisibleTableData();
            var csv = this.toolbarBuildCSV(rows);
            this.toolbarTriggerDownload(csv, 'table-export.csv', 'text/csv;charset=utf-8;');
        },

        toolbarDownloadExcel: function () {
            // Build a CSV with BOM for Excel compatibility; use vnd.ms-excel MIME.
            var rows = this.getVisibleTableData();
            var csv = '﻿' + this.toolbarBuildCSV(rows); // BOM for Excel
            this.toolbarTriggerDownload(csv, 'table-export.xls', 'application/vnd.ms-excel;charset=utf-8;');
        },

        toolbarDownloadPDF: function () {
            // Route through the existing #531 Print button when present.
            // Fall back to window.print() when the Print button is suppressed
            // via #521 show_print=false. See v4.8.15 — #636 closed honestly.
            var $wrapper = $('#' + this.wrapperId);
            var $printBtn = $wrapper.find('.gt-print-btn').first();
            if ($printBtn.length) {
                $printBtn.trigger('click');
                return;
            }
            window.print();
        },

        // #2285 — JSON visible-rows export helpers.

        /**
         * toolbarBuildJSON(rows)
         *
         * Takes a 2D array of cell values (same shape as getVisibleTableData)
         * and returns a pretty-printed JSON string — an array of objects keyed
         * by the column headers from the first row.
         *
         * @param  {string[][]} rows  2D array; rows[0] is the header row.
         * @return {string}           JSON string.
         */
        toolbarBuildJSON: function (rows) {
            if (!rows.length) { return '[]'; }
            var headers = rows[0];
            var data = rows.slice(1).map(function (row) {
                var obj = {};
                headers.forEach(function (h, i) {
                    obj[h] = row[i] !== undefined ? row[i] : '';
                });
                return obj;
            });
            return JSON.stringify(data, null, 2);
        },

        /**
         * toolbarDownloadJSON()
         *
         * Orchestrator: getVisibleTableData → toolbarBuildJSON →
         * toolbarTriggerDownload as application/json.
         */
        toolbarDownloadJSON: function () {
            var rows = this.getVisibleTableData();
            var json = this.toolbarBuildJSON(rows);
            this.toolbarTriggerDownload(json, 'table-export.json', 'application/json;charset=utf-8;');
        }

    });

})(window);

/* ===== assets/js/frontend/filter-state-persistence.js ===== */
/**
 * TableCrafter — frontend/filter-state-persistence.js
 *
 * localStorage persistence of search term + per-column filter state.
 * #832 slice 14 of N.
 *
 * Four helpers attached to GravityTable.prototype via Object.assign:
 *
 *   - _filterStateStorageKey()
 *       Returns the localStorage key for this table. Format:
 *       `gt-filters-<table_id>` when this.config.table_id is set,
 *       otherwise falls back to `gt-filters-<wrapperId>`.
 *
 *   - persistFilterStateLocal()
 *       No-ops when `this.config.persist_filters_localstorage` is
 *       falsy or localStorage is unavailable. Otherwise writes
 *       `{ searchTerm, filters }` to the key. Errors are swallowed.
 *
 *   - restoreFilterStateLocal()
 *       No-ops the same way. Otherwise reads + merges into
 *       `this.searchTerm` and `this.filters` — URL takes precedence
 *       (only fills slots that applyUrlFilters left empty). Returns
 *       true on a successful restore.
 *
 *   - clearFilterStateLocal()
 *       Removes the key from localStorage when available.
 *
 * Depends on jQuery for the DOM sync after restore (search input +
 * per-column filter inputs).
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    Object.assign(window.GravityTable.prototype, {

        _filterStateStorageKey: function () {
            var id = (this.config && this.config.table_id) || this.wrapperId;
            return 'gt-filters-' + id;
        },

        persistFilterStateLocal: function () {
            if (!(this.config && this.config.persist_filters_localstorage)) return;
            if (!window.localStorage) return;
            try {
                var state = {
                    searchTerm: String(this.searchTerm || ''),
                    filters: this.filters && typeof this.filters === 'object' ? this.filters : {}
                };
                localStorage.setItem(this._filterStateStorageKey(), JSON.stringify(state));
            } catch (e) {}
        },

        restoreFilterStateLocal: function () {
            if (!(this.config && this.config.persist_filters_localstorage)) return false;
            if (!window.localStorage) return false;
            try {
                var raw = localStorage.getItem(this._filterStateStorageKey());
                if (!raw) return false;
                var state = JSON.parse(raw);
                if (!state || typeof state !== 'object') return false;
                if (typeof state.searchTerm === 'string' && state.searchTerm !== '') {
                    this.searchTerm = state.searchTerm;
                    $('#' + this.wrapperId).find('.gt-search-input').val(state.searchTerm);
                }
                if (state.filters && typeof state.filters === 'object') {
                    // Merge rather than replace — applyUrlFilters may have already
                    // seeded entries from the URL, and URL takes precedence.
                    for (var k in state.filters) {
                        if (Object.prototype.hasOwnProperty.call(state.filters, k) && !this.filters[k]) {
                            this.filters[k] = state.filters[k];
                            var fv = state.filters[k];
                            var colId = String(k).replace(/^filter_/, '');
                            var $input = $('#' + this.wrapperId).find('#gt-filter-' + colId);
                            if ($input.length && fv && typeof fv === 'object' && typeof fv.value !== 'undefined') {
                                $input.val(fv.value);
                            }
                        }
                    }
                }
                return true;
            } catch (e) { return false; }
        },

        clearFilterStateLocal: function () {
            if (!window.localStorage) return;
            try { localStorage.removeItem(this._filterStateStorageKey()); } catch (e) {}
        }

    });

})(window);

/* ===== assets/js/frontend/column-order-persistence.js ===== */
/**
 * TableCrafter — frontend/column-order-persistence.js
 *
 * localStorage persistence of the user's per-table column order.
 * #832 slice 15 of N.
 *
 * Four helpers attached to GravityTable.prototype via Object.assign:
 *
 *   - _columnOrderKey()
 *       Returns the localStorage key for this table. Format:
 *       `gt-col-order-<table_id_or_id>` when this.config.table_id or
 *       this.config.id is set, otherwise falls back to
 *       `gt-col-order-<wrapperId>`.
 *
 *   - readStoredColumnOrder()
 *       Returns the saved order as an array of stringified field IDs,
 *       or `[]` when nothing is stored / payload is malformed /
 *       localStorage is unavailable. Errors are swallowed.
 *
 *   - saveStoredColumnOrder(order)
 *       Writes the supplied order (array of any) to the key, coercing
 *       each entry to a string. Errors are swallowed.
 *
 *   - clearStoredColumnOrder()
 *       Removes the key from localStorage. Errors are swallowed.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    Object.assign(window.GravityTable.prototype, {

        _columnOrderKey: function () {
            var id = (this.config && (this.config.table_id || this.config.id)) || this.wrapperId;
            return 'gt-col-order-' + id;
        },

        readStoredColumnOrder: function () {
            try {
                var raw = window.localStorage && localStorage.getItem(this._columnOrderKey());
                if (!raw) return [];
                var arr = JSON.parse(raw);
                return Array.isArray(arr) ? arr.map(String) : [];
            } catch (e) { return []; }
        },

        saveStoredColumnOrder: function (order) {
            try { localStorage.setItem(this._columnOrderKey(), JSON.stringify(order.map(String))); } catch (e) {}
        },

        clearStoredColumnOrder: function () {
            try { localStorage.removeItem(this._columnOrderKey()); } catch (e) {}
        }

    });

})(window);

/* ===== assets/js/frontend/column-visibility-picker.js ===== */
(function (window) {
    'use strict';

    if (!window.GravityTable) { return; }

    function storageKey(tableId) {
        return 'gt_col_vis_' + tableId;
    }

    GravityTable.prototype.toggleColumnVisibility = function (fieldId, visible) {
        var wrapper = document.getElementById(this.wrapperId);
        if (!wrapper) { return; }
        var display = visible ? '' : 'none';
        var th = wrapper.querySelector('th[data-field-id="' + fieldId + '"]');
        if (th) { th.style.display = display; }
        var tds = wrapper.querySelectorAll('td[data-field-id="' + fieldId + '"]');
        for (var i = 0; i < tds.length; i++) { tds[i].style.display = display; }

        var key = storageKey(this.config && this.config.table_id || '');
        var stored = {};
        try { stored = JSON.parse(window.localStorage.getItem(key) || '{}'); } catch (e) {}
        stored[String(fieldId)] = visible;
        try { window.localStorage.setItem(key, JSON.stringify(stored)); } catch (e) {}
    };

    GravityTable.prototype.restoreColumnVisibility = function () {
        var key = storageKey(this.config && this.config.table_id || '');
        var stored = {};
        try { stored = JSON.parse(window.localStorage.getItem(key) || '{}'); } catch (e) {}
        for (var fieldId in stored) {
            if (Object.prototype.hasOwnProperty.call(stored, fieldId) && stored[fieldId] === false) {
                this.toggleColumnVisibility(fieldId, false);
            }
        }
    };

    GravityTable.prototype.initColumnPicker = function () {
        var config = this.config || {};
        if (!config.show_column_picker) { return; }
        var wrapper = document.getElementById(this.wrapperId);
        if (!wrapper) { return; }
        var toolbar = wrapper.querySelector('.gt-table-controls');
        if (!toolbar) { return; }

        var self = this;
        var tableId = config.table_id || '';
        var storedKey = storageKey(tableId);
        var stored = {};
        try { stored = JSON.parse(window.localStorage.getItem(storedKey) || '{}'); } catch (e) {}

        var ths = wrapper.querySelectorAll('th[data-field-id]');
        if (!ths.length) { return; }

        var pickerWrap = document.createElement('div');
        pickerWrap.className = 'gt-column-picker';
        pickerWrap.style.position = 'relative';
        pickerWrap.style.display = 'inline-block';

        var btn = document.createElement('button');
        btn.type = 'button';
        btn.className = 'gt-column-picker-btn gt-toolbar-btn';
        btn.textContent = 'Columns';
        btn.setAttribute('aria-expanded', 'false');

        var dropdown = document.createElement('div');
        dropdown.className = 'gt-column-picker-dropdown';
        dropdown.style.display = 'none';
        dropdown.style.position = 'absolute';
        dropdown.style.background = '#fff';
        dropdown.style.border = '1px solid #ccc';
        dropdown.style.borderRadius = '4px';
        dropdown.style.padding = '8px';
        dropdown.style.zIndex = '999';
        dropdown.style.minWidth = '150px';
        dropdown.style.boxShadow = '0 2px 8px rgba(0,0,0,.12)';
        dropdown.style.top = '100%';
        dropdown.style.left = '0';

        for (var i = 0; i < ths.length; i++) {
            var th = ths[i];
            var fieldId = th.getAttribute('data-field-id');
            var label = th.textContent.trim();
            var isVisible = stored.hasOwnProperty(fieldId) ? stored[fieldId] !== false : true;

            var item = document.createElement('label');
            item.className = 'gt-column-picker-item';
            item.style.display = 'flex';
            item.style.alignItems = 'center';
            item.style.gap = '6px';
            item.style.cursor = 'pointer';
            item.style.padding = '3px 0';
            item.style.whiteSpace = 'nowrap';

            var cb = document.createElement('input');
            cb.type = 'checkbox';
            cb.checked = isVisible;
            cb.setAttribute('data-field-id', fieldId);

            (function (fid) {
                cb.addEventListener('change', function () {
                    self.toggleColumnVisibility(fid, this.checked);
                });
            })(fieldId);

            item.appendChild(cb);
            item.appendChild(document.createTextNode(label));
            dropdown.appendChild(item);
        }

        btn.addEventListener('click', function (e) {
            e.stopPropagation();
            var isOpen = dropdown.style.display !== 'none';
            dropdown.style.display = isOpen ? 'none' : 'block';
            btn.setAttribute('aria-expanded', String(!isOpen));
        });

        document.addEventListener('click', function () {
            dropdown.style.display = 'none';
            btn.setAttribute('aria-expanded', 'false');
        });

        pickerWrap.appendChild(btn);
        pickerWrap.appendChild(dropdown);
        toolbar.appendChild(pickerWrap);
    };

})(window);

/* ===== assets/js/frontend/bulk-column-fill.js ===== */
/**
 * TableCrafter — frontend/bulk-column-fill.js
 *
 * Pro-gated bulk column fill for selected rows.
 * Opens a modal with a field selector + value input;
 * POSTs to gt_bulk_fill_column AJAX handler on confirm.
 *
 * Closes #1745.
 *
 * Surface (GravityTable.prototype):
 *   - openBulkFillModal(entryIds)   — builds and shows the fill modal.
 *   - executeBulkFill({entryIds, fieldId, value}) — fires AJAX.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery || window.$;

    GravityTable.prototype.openBulkFillModal = function (entryIds) {
        var config = this.config || {};
        if (!config.is_pro) { return; }
        if (!entryIds || !entryIds.length) { return; }

        var self = this;
        // Build field_labels from column_config (field_id => label) so we don't need
        // a separate config key. Falls back to explicit field_labels if provided.
        var fieldLabels = config.field_labels || {};
        if (!Object.keys(fieldLabels).length && config.column_config) {
            var cc = config.column_config;
            Object.keys(cc).forEach(function (fid) {
                if (cc[fid] && cc[fid].label) { fieldLabels[fid] = cc[fid].label; }
            });
        }
        var fields = Object.keys(fieldLabels);
        if (!fields.length) { return; }

        document.getElementById('gt-bulk-fill-modal') && document.getElementById('gt-bulk-fill-modal').remove();

        var optionsHtml = fields.map(function (fid) {
            return '<option value="' + fid + '">' + (fieldLabels[fid] || fid) + '</option>';
        }).join('');

        var modal = document.createElement('div');
        modal.id = 'gt-bulk-fill-modal';
        modal.className = 'gt-modal-overlay';
        modal.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);z-index:99999;display:flex;align-items:center;justify-content:center;';
        modal.innerHTML =
            '<div class="gt-modal-dialog" style="background:#fff;border-radius:6px;padding:24px;min-width:320px;max-width:480px;box-shadow:0 4px 24px rgba(0,0,0,.3);">' +
            '<h3 class="gt-modal-title">Fill Column for ' + entryIds.length + ' Row' + (entryIds.length === 1 ? '' : 's') + '</h3>' +
            '<div class="gt-modal-body">' +
            '<label class="gt-field-label">Column<br>' +
            '<select name="gt_fill_field">' + optionsHtml + '</select>' +
            '</label>' +
            '<label class="gt-field-label" style="margin-top:10px;">Value<br>' +
            '<input type="text" name="gt_fill_value" value="" placeholder="New value" style="width:100%;">' +
            '</label>' +
            '</div>' +
            '<div class="gt-modal-footer">' +
            '<button type="button" class="gt-bulk-fill-confirm button button-primary">Apply</button>' +
            '<button type="button" class="gt-bulk-fill-cancel button">Cancel</button>' +
            '</div>' +
            '</div>';

        document.body.appendChild(modal);

        // #1749 — live diff preview on the Apply button (Pro).
        var $applyBtn = modal.querySelector('.gt-bulk-fill-confirm');
        var $valInput = modal.querySelector('input[name="gt_fill_value"]');
        var $fieldSel = modal.querySelector('select[name="gt_fill_field"]');
        var _previewTimer = null;
        function updatePreview() {
            if (typeof self.updateBulkFillPreview === 'function') {
                var label = $fieldSel.options[$fieldSel.selectedIndex] ? $fieldSel.options[$fieldSel.selectedIndex].text : '';
                self._bulkFillApplyBtn = $applyBtn;
                self.updateBulkFillPreview(entryIds.length, label, $valInput.value.trim());
            }
        }
        $valInput.addEventListener('input', function () {
            clearTimeout(_previewTimer);
            _previewTimer = setTimeout(updatePreview, 300);
        });
        $fieldSel.addEventListener('change', updatePreview);

        modal.querySelector('.gt-bulk-fill-cancel').addEventListener('click', function () {
            modal.remove();
        });

        modal.querySelector('.gt-bulk-fill-confirm').addEventListener('click', function () {
            var fieldId = modal.querySelector('select[name="gt_fill_field"]').value;
            var value   = modal.querySelector('input[name="gt_fill_value"]').value;
            modal.remove();
            self.executeBulkFill({ entryIds: entryIds, fieldId: fieldId, value: value });
        });
    };

    GravityTable.prototype.executeBulkFill = function (opts) {
        var config    = this.config || {};
        if (!config.is_pro) { return; }

        var entryIds  = (opts && opts.entryIds) || [];
        var fieldId   = (opts && opts.fieldId)  || '';
        var value     = (opts && opts.value !== undefined) ? opts.value : '';

        if (!entryIds.length || !fieldId) { return; }

        var self = this;
        var ajaxUrl = config.ajax_url || (window.ajaxurl || '');

        $.post(ajaxUrl, {
            action:    'gt_bulk_fill_column',
            nonce:     config.nonce || '',
            table_id:  config.table_id || '',
            entry_ids: entryIds,
            field_id:  fieldId,
            value:     value,
        })
        .done(function (response) {
            if (response && response.success) {
                if (typeof self.loadEntries === 'function') {
                    self.loadEntries();
                }
            }
        })
        .fail(function () {
            /* silently ignore network failures — table state unchanged */
        });
    };

}(window));

/* ===== assets/js/frontend/column-role-visibility.js ===== */
/**
 * TableCrafter — frontend/column-role-visibility.js
 *
 * Pro-gated per-column role visibility. Hides th + td elements for
 * columns whose allowed-roles list does not include any role the current
 * user holds. Called once from init() after the DOM is rendered.
 *
 * Closes #1746.
 *
 * Surface (GravityTable.prototype):
 *   - applyColumnRoleVisibility() — reads config.column_role_visibility
 *     (field_id => roles[]) and config.user_roles, hides/shows columns.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        window.GravityTable = function GravityTable() {};
    }

    GravityTable.prototype.applyColumnRoleVisibility = function () {
        var config = this.config || {};
        if (!config.is_pro) { return; }

        var visMap   = config.column_role_visibility || {};
        var userRoles = config.user_roles || [];
        var wrapper  = document.getElementById(this.wrapperId);
        if (!wrapper) { return; }

        Object.keys(visMap).forEach(function (fieldId) {
            var allowedRoles = visMap[fieldId];
            if (!allowedRoles || !allowedRoles.length) { return; }

            var hasRole = allowedRoles.some(function (r) {
                return userRoles.indexOf(r) !== -1;
            });
            var display = hasRole ? '' : 'none';

            var cells = wrapper.querySelectorAll('[data-field-id="' + fieldId + '"]');
            for (var i = 0; i < cells.length; i++) {
                cells[i].style.display = display;
            }
        });
    };

}(window));

/* ===== assets/js/frontend/entry-duplicate.js ===== */
/**
 * TableCrafter — frontend/entry-duplicate.js
 *
 * Pro-gated one-click entry duplicate. POSTs to gt_duplicate_entry
 * AJAX handler which creates a new GF entry copying all field values
 * from the source entry.
 *
 * Closes #1747.
 *
 * Surface (GravityTable.prototype):
 *   - duplicateEntry(entryId) — fires AJAX, reloads on success.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery || window.$;

    GravityTable.prototype.duplicateEntry = function (entryId) {
        var config = this.config || {};
        if (!config.is_pro) { return; }
        if (!entryId) { return; }

        var self = this;
        var ajaxUrl = config.ajax_url || (window.ajaxurl || '');

        $.post(ajaxUrl, {
            action:   'gt_duplicate_entry',
            nonce:    config.nonce || '',
            table_id: config.table_id || '',
            entry_id: entryId,
        })
        .done(function (response) {
            if (response && response.success) {
                if (typeof self.loadEntries === 'function') {
                    self.loadEntries();
                }
            }
        })
        .fail(function () {
            /* silently ignore network failures */
        });
    };

}(window));

/* ===== assets/js/frontend/row-diff.js ===== */
/* #1749 — Inline cell diff badge + bulk fill diff preview. */

/**
 * Show a brief "was: {oldValue}" badge on a cell after an inline edit
 * changes its value. Badge auto-removes after 3 seconds.
 *
 * Free feature — no Pro gate needed (purely cosmetic feedback).
 *
 * @param {Element} cell       The td/th element that was just saved.
 * @param {string}  oldValue   The value before the edit.
 * @param {string}  newValue   The value after the edit.
 */
GravityTable.prototype.showDiffBadge = function (cell, oldValue, newValue) {
    if (!cell || oldValue === newValue) { return; }

    // Remove any existing badge on this cell.
    var existing = cell.querySelector('.gt-diff-badge');
    if (existing) { existing.remove(); }

    var badge = document.createElement('span');
    badge.className = 'gt-diff-badge';
    badge.textContent = '← was: ' + oldValue;
    badge.style.cssText = [
        'display:inline-block',
        'margin-left:6px',
        'font-size:11px',
        'color:#6b7280',
        'background:rgba(0,0,0,0.06)',
        'border-radius:4px',
        'padding:1px 6px',
        'transition:opacity 600ms ease',
        'opacity:1',
        'pointer-events:none',
        'vertical-align:middle',
    ].join(';');

    cell.appendChild(badge);

    // Fade out after 2.4s, remove after 3s.
    var timer = setTimeout(function () {
        badge.style.opacity = '0';
        setTimeout(function () { badge.remove(); }, 600);
    }, 2400);

    // Store timer so tests can clear if needed.
    badge._timer = timer;
};

/**
 * Update the bulk fill modal's Apply button to preview what will happen (Pro).
 * Shows "Apply to {N} rows → '{field}': '{value}'" so the user can confirm
 * before committing. Degrades to plain "Apply" on free or when value is empty.
 *
 * @param {number} rowCount   Number of rows that will be affected.
 * @param {string} fieldLabel Human-readable field label.
 * @param {string} value      The value that will be written.
 */
GravityTable.prototype.updateBulkFillPreview = function (rowCount, fieldLabel, value) {
    var config = this.config || {};
    // Prefer the stored reference injected by bulk-column-fill.js;
    // fall back to a DOM query so tests don't need to mock it.
    var btn = this._bulkFillApplyBtn || document.querySelector('.gt-bulk-fill-apply') || document.querySelector('.gt-bulk-fill-confirm');
    if (!btn) { return; }

    if (!config.is_pro || !value) {
        btn.textContent = 'Apply';
        return;
    }

    btn.textContent = 'Apply to ' + rowCount + ' row' + (rowCount !== 1 ? 's' : '') + ' → “' + value + '”';
};

/* ===== assets/js/frontend/post-render-gates.js ===== */
/**
 * TableCrafter — frontend/post-render-gates.js
 *
 * Post-render DOM gates triggered at the tail of renderEntries.
 * #832 slice 18 of N.
 *
 * Two helpers attached to GravityTable.prototype via Object.assign:
 *
 *   - applyRowExpiry()
 *       #501: hide/strikethrough/sink-to-bottom rows whose date in
 *       `config.expiry_field_id` is past (or future, in inverse mode).
 *       Admin preview bails when config.is_preview is true. Fail-open
 *       on un-parseable date values. ISO date-only strings parse as
 *       local midnight to avoid timezone-induced false expiry (#501
 *       slice 2.1). Inverse-mode future rows get the .gt-row-future
 *       class; past-expired rows get .gt-row-expired (#501 slice 2).
 *
 *   - applyAutoMerge()
 *       #518: rowspan auto-merge. For each column flagged
 *       `config.column_auto_merge[field_id]=true`, collapses runs of
 *       consecutive cells with identical text content into a single
 *       `rowspan=N` cell. Empty cells terminate runs and don't start
 *       new ones (#518 slice 2). Skips `.gt-totals-row` (#518 slice
 *       2.1) — merging totals would visually misrepresent them.
 *       Re-runs from sort/filter/search are automatic because this
 *       method is invoked at the tail of renderEntries.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    Object.assign(window.GravityTable.prototype, {

        applyRowExpiry: function () {
            var cfg = this.config || {};
            if (cfg.is_preview) { return; }
            var field = cfg.expiry_field_id ? String(cfg.expiry_field_id) : '';
            if (!field) { return; }
            // #501 slice 2.2: accept both 'bottom' (legacy v4.7.78) and
            // 'move_bottom' (canonical name in TC_Row_Expiry_Service PHP class).
            var rawBehavior = cfg.expiry_behavior;
            if (rawBehavior === 'move_bottom') { rawBehavior = 'bottom'; }
            var behavior = rawBehavior === 'strikethrough' || rawBehavior === 'bottom'
                ? rawBehavior : 'hide';
            var graceDays = Math.max(0, parseInt(cfg.expiry_grace_days, 10) || 0);
            var inverse = !!cfg.expiry_inverse;

            var $wrapper = $('#' + this.wrapperId);
            var $tbody = $wrapper.find('table.gt-table > tbody').first();
            if (!$tbody.length) { return; }

            // Anchor: now + grace (for normal mode) or now (for inverse mode).
            var now = Date.now();
            var graceMs = graceDays * 24 * 60 * 60 * 1000;
            var threshold = inverse ? (now + graceMs) : (now - graceMs);

            var sinkToBottom = [];
            $tbody.children('tr').each(function () {
                var $tr = $(this);
                var $cell = $tr.find('td[data-field-id="' + field + '"]').first();
                if (!$cell.length) { return; }
                var raw = ($cell.text() || '').trim();
                if (!raw) { return; }
                // #501 slice 2.1: ISO date-only strings parse as UTC midnight
                // by default — but we compare against local Date.now(), so
                // construct the Date with local-midnight semantics instead.
                var t;
                var isoOnly = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/);
                if (isoOnly) {
                    t = new Date(+isoOnly[1], +isoOnly[2] - 1, +isoOnly[3]).getTime();
                } else {
                    t = Date.parse(raw);
                }
                if (isNaN(t)) { return; } // fail-open
                var isExpired = inverse ? (t > threshold) : (t < threshold);
                if (!isExpired) { return; }
                // #501 slice 2: distinct class per semantic.
                var stateClass = inverse ? 'gt-row-future' : 'gt-row-expired';
                if (behavior === 'hide') {
                    $tr.addClass(stateClass).hide();
                } else if (behavior === 'strikethrough') {
                    $tr.addClass(stateClass);
                } else if (behavior === 'bottom') {
                    $tr.addClass(stateClass);
                    sinkToBottom.push($tr);
                }
            });
            for (var i = 0; i < sinkToBottom.length; i++) {
                sinkToBottom[i].detach().appendTo($tbody);
            }
        },

        applyAutoMerge: function () {
            var cfg = this.config && this.config.column_auto_merge;
            if (!cfg) { return; }
            var fields = [];
            for (var k in cfg) {
                if (Object.prototype.hasOwnProperty.call(cfg, k) && cfg[k]) { fields.push(String(k)); }
            }
            if (fields.length === 0) { return; }

            var $wrapper = $('#' + this.wrapperId);
            var $tbody = $wrapper.find('table.gt-table > tbody').first();
            if (!$tbody.length) { return; }

            // #518 slice 2.1: exclude the column-totals row.
            var rows = $tbody.children('tr').not('.gt-totals-row').toArray();
            if (rows.length < 2) { return; }

            for (var fi = 0; fi < fields.length; fi++) {
                var fieldId = fields[fi];
                var firstCellOfRun = null;
                var runValue = null;
                var runLength = 0;
                var toRemove = [];

                for (var ri = 0; ri < rows.length; ri++) {
                    var $cell = $(rows[ri]).find('td[data-field-id="' + fieldId + '"]').first();
                    if (!$cell.length) {
                        if (firstCellOfRun && runLength > 1) {
                            firstCellOfRun.attr('rowspan', String(runLength));
                        }
                        firstCellOfRun = null;
                        runValue = null;
                        runLength = 0;
                        continue;
                    }
                    var thisVal = ($cell.text() || '').replace(/\s+/g, ' ').trim();
                    // #518 slice 2: skip empty cells entirely.
                    if (thisVal === '') {
                        if (firstCellOfRun && runLength > 1) {
                            firstCellOfRun.attr('rowspan', String(runLength));
                        }
                        firstCellOfRun = null;
                        runValue = null;
                        runLength = 0;
                        continue;
                    }
                    if (firstCellOfRun !== null && thisVal === runValue) {
                        runLength++;
                        toRemove.push($cell);
                    } else {
                        if (firstCellOfRun && runLength > 1) {
                            firstCellOfRun.attr('rowspan', String(runLength));
                        }
                        firstCellOfRun = $cell;
                        runValue = thisVal;
                        runLength = 1;
                    }
                }
                if (firstCellOfRun && runLength > 1) {
                    firstCellOfRun.attr('rowspan', String(runLength));
                }
                for (var di = 0; di < toRemove.length; di++) {
                    toRemove[di].remove();
                }
            }
        }

    });

})(window);

/* ===== assets/js/frontend/column-reorder-dnd.js ===== */
/**
 * TableCrafter — frontend/column-reorder-dnd.js
 *
 * Column reorder drag-and-drop UI. #832 slice 19 of N.
 *
 * Eight helpers attached to GravityTable.prototype via Object.assign.
 * Pairs with column-order-persistence.js (#832 slice 15) — these methods
 * provide the UI, the persistence module provides the storage.
 *
 *   - getReorderableHeaders()       — return jQuery set of draggable <th>s
 *   - fieldIdFromTh(th)             — pure: extract field id from gt-column-* class
 *   - applyStoredColumnOrderToHead()— reorder thead per stored order, sync config.columns
 *   - applyStoredColumnOrderToBody()— reorder tbody cells to match stored order
 *   - bindColumnDragEvents()        — wire dragstart/dragover/drop on headers
 *   - reorderColumn(src, tgt)       — compute new order, persist, re-apply, update reset btn
 *   - initColumnReorder()           — entry point: skip on touch-only, inject reset button,
 *                                     snapshot defaults, apply stored order, bind drag, update btn
 *   - updateColumnReorderResetButton() — toggle reset button visibility based on stored order
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    Object.assign(window.GravityTable.prototype, {

        getReorderableHeaders: function () {
            var $wrapper = $('#' + this.wrapperId);
            return $wrapper.find('thead tr th').filter(function () {
                return /(?:^|\s)gt-column-/.test(this.className) && !$(this).hasClass('gt-actions-header') && !$(this).hasClass('gt-selection-header');
            });
        },

        fieldIdFromTh: function (th) {
            var m = /(?:^|\s)gt-column-([^\s]+)/.exec(th.className);
            return m ? m[1] : null;
        },

        applyStoredColumnOrderToHead: function () {
            var self = this;
            var $wrapper = $('#' + this.wrapperId);
            var saved = this.readStoredColumnOrder();
            if (!saved.length) return;

            var $headerRow = $wrapper.find('thead tr').first();
            if (!$headerRow.length) return;

            var thMap = {};
            $headerRow.find('th').each(function () {
                var fid = self.fieldIdFromTh(this);
                if (fid) thMap[fid] = this;
            });

            var $insertAfter = $headerRow.find('th.gt-selection-header').last();
            var $beforeActions = $headerRow.find('th.gt-actions-header').first();

            saved.forEach(function (fid) {
                var th = thMap[fid];
                if (!th) return;
                if ($beforeActions.length) {
                    $beforeActions.before(th);
                } else if ($insertAfter.length) {
                    $insertAfter.after(th);
                    $insertAfter = $(th);
                } else {
                    $headerRow.append(th);
                }
            });

            // Sync config.columns so future renderEntries match.
            if (Array.isArray(this.config.columns)) {
                var pinned = this.config.columns.filter(function (c) { return saved.indexOf(String(c)) === -1; });
                this.config.columns = saved.concat(pinned);
            }
        },

        applyStoredColumnOrderToBody: function () {
            var saved = this.readStoredColumnOrder();
            if (!saved.length) return;
            // #1049 Option 2 v4.220.0 — hot-loop perf refactor. Behavior pinned
            // by 4 e2e vitest tests; equivalence verified pre/post.
            // 1. $.each -> native for-loop on the row list.
            // 2. $(this).attr -> el.getAttribute (saves a jQuery wrap per cell).
            // 3. $row.find('td[data-field-id]').each -> single querySelectorAll
            //    + native for-loop.
            // 4. $beforeActions.before(cell) / $row.append(cell) ->
            //    parentNode.insertBefore / rowEl.appendChild (saves a jQuery
            //    wrap per move).
            var wrapperEl = document.getElementById(this.wrapperId);
            if (!wrapperEl) return;
            var rowEls = wrapperEl.querySelectorAll('tbody tr');
            for (var ri = 0, rn = rowEls.length; ri < rn; ri++) {
                var rowEl = rowEls[ri];
                var cellEls = rowEl.querySelectorAll('td[data-field-id]');
                var cellMap = {};
                for (var ci = 0, cn = cellEls.length; ci < cn; ci++) {
                    var ce = cellEls[ci];
                    cellMap[String(ce.getAttribute('data-field-id'))] = ce;
                }
                var beforeActionsEl = rowEl.querySelector('td.gt-actions-cell');
                for (var si = 0, sn = saved.length; si < sn; si++) {
                    var cell = cellMap[saved[si]];
                    if (!cell) continue;
                    if (beforeActionsEl) {
                        beforeActionsEl.parentNode.insertBefore(cell, beforeActionsEl);
                    } else {
                        rowEl.appendChild(cell);
                    }
                }
            }
        },

        bindColumnDragEvents: function () {
            var self = this;
            var $wrapper = $('#' + this.wrapperId);
            var $headers = this.getReorderableHeaders();
            $headers.attr('draggable', 'true').addClass('gt-col-draggable');

            $headers.off('.gtcoldrag');
            $headers.on('dragstart.gtcoldrag', function (e) {
                var fid = self.fieldIdFromTh(this);
                if (!fid) return;
                $(this).addClass('gt-col-dragging');
                try { e.originalEvent.dataTransfer.setData('text/plain', fid); } catch (err) {}
                try { e.originalEvent.dataTransfer.effectAllowed = 'move'; } catch (err) {}
            });

            $headers.on('dragend.gtcoldrag', function () {
                $wrapper.find('th.gt-col-dragging, th.gt-col-drop-target').removeClass('gt-col-dragging gt-col-drop-target');
            });

            $headers.on('dragover.gtcoldrag', function (e) {
                e.preventDefault();
                try { e.originalEvent.dataTransfer.dropEffect = 'move'; } catch (err) {}
                $wrapper.find('th.gt-col-drop-target').removeClass('gt-col-drop-target');
                $(this).addClass('gt-col-drop-target');
            });

            $headers.on('dragleave.gtcoldrag', function () {
                $(this).removeClass('gt-col-drop-target');
            });

            $headers.on('drop.gtcoldrag', function (e) {
                e.preventDefault();
                var sourceFid = '';
                try { sourceFid = e.originalEvent.dataTransfer.getData('text/plain'); } catch (err) {}
                var targetFid = self.fieldIdFromTh(this);
                $wrapper.find('th.gt-col-drop-target, th.gt-col-dragging').removeClass('gt-col-drop-target gt-col-dragging');
                if (!sourceFid || !targetFid || sourceFid === targetFid) return;
                self.reorderColumn(sourceFid, targetFid);
            });
        },

        reorderColumn: function (sourceFid, targetFid) {
            var current = [];
            var self = this;
            this.getReorderableHeaders().each(function () {
                current.push(self.fieldIdFromTh(this));
            });
            var srcIdx = current.indexOf(sourceFid);
            var tgtIdx = current.indexOf(targetFid);
            if (srcIdx === -1 || tgtIdx === -1) return;
            current.splice(srcIdx, 1);
            current.splice(tgtIdx, 0, sourceFid);
            this.saveStoredColumnOrder(current);
            this.applyStoredColumnOrderToHead();
            this.applyStoredColumnOrderToBody();
            this.updateColumnReorderResetButton();
        },

        initColumnReorder: function () {
            var $wrapper = $('#' + this.wrapperId);

            // Skip on touch-primary devices (mobile) — column drag isn't practical there.
            var isTouchOnly = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
            if (isTouchOnly) return;

            // Inject Reset button into the table controls if not already present.
            if (!$wrapper.find('.gt-col-reset-btn').length) {
                var $controls = $wrapper.find('.gt-table-controls').first();
                if ($controls.length) {
                    $controls.append('<button type="button" class="gt-col-reset-btn" style="display:none" title="Reset column order to default">Reset Columns</button>');
                }
            }

            var self = this;
            $wrapper.on('click', '.gt-col-reset-btn', function () {
                self.clearStoredColumnOrder();
                // Restore default column order from config and reapply.
                if (self.config && Array.isArray(self.config._defaultColumns)) {
                    self.config.columns = self.config._defaultColumns.slice();
                }
                self.loadEntries();
                // Reload page to get default thead order back from server.
                window.setTimeout(function () { window.location.reload(); }, 50);
            });

            // Snapshot the original column order from server-rendered config.
            if (this.config && Array.isArray(this.config.columns) && !this.config._defaultColumns) {
                this.config._defaultColumns = this.config.columns.slice();
            }

            this.applyStoredColumnOrderToHead();
            this.applyStoredColumnOrderToBody();
            this.bindColumnDragEvents();
            this.updateColumnReorderResetButton();
        },

        updateColumnReorderResetButton: function () {
            var $wrapper = $('#' + this.wrapperId);
            var hasCustom = this.readStoredColumnOrder().length > 0;
            $wrapper.find('.gt-col-reset-btn').toggle(hasCustom);
        }

    });

})(window);

/* ===== assets/js/frontend/responsive-card-view.js ===== */
/**
 * TableCrafter — frontend/responsive-card-view.js
 *
 * Responsive view + card-view rendering. #832 slice 20 of N.
 *
 * Thirteen helpers attached to GravityTable.prototype directly (kept as
 * `GravityTable.prototype.X = function...` assignments rather than the
 * Object.assign property-shorthand pattern used by other modules — the
 * block is large enough that re-folding into Object.assign would risk
 * subtle method-body changes during the move).
 *
 *   - initializeResponsiveMode($wrapper)
 *   - setupResponsiveBreakpoints($wrapper)
 *   - handleResponsiveResize($wrapper)
 *   - initFlipResponsive($wrapper)
 *   - initCollapsibleRows($wrapper)
 *   - initModalRows($wrapper)
 *   - shouldShowCardsOnTablet()
 *   - showCardView($wrapper)
 *   - showTableView($wrapper)
 *   - generateCards($wrapper)
 *   - generateCardHtml($row, entryId, isTabletView)
 *   - updateCardsAfterDataChange($wrapper)
 *   - bindCardEvents($wrapper)
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    // Local alias so the block below (kept verbatim from frontend.js)
    // reads `GravityTable.prototype.X = function...` without
    // window-qualified names.
    var GravityTable = window.GravityTable;
    var $ = window.jQuery;

    GravityTable.prototype.initializeResponsiveMode = function ($wrapper) {
        var self = this;

        //console.log('GT Frontend: Initializing responsive mode:', this.config.responsive_mode);

        // Set up responsive mode handling based on screen size
        this.setupResponsiveBreakpoints($wrapper);

        // Handle window resize to toggle between table and card views (with throttling)
        var resizeTimeout;
        $(window).on('resize.gravityTable.' + this.wrapperId, function () {
            clearTimeout(resizeTimeout);
            resizeTimeout = setTimeout(function () {
                self.handleResponsiveResize($wrapper);
            }, 100);
        });

        // ResizeObserver catches browser zoom and container-width changes that may not
        // fire a window resize event in all browsers (#156)
        if (typeof ResizeObserver !== 'undefined' && $wrapper.length) {
            /* c8 ignore next */
            var ro = new ResizeObserver(function () {
                /* c8 ignore next */
                clearTimeout(resizeTimeout);
                /* c8 ignore next */
                resizeTimeout = setTimeout(function () {
                    /* c8 ignore next */
                    self.handleResponsiveResize($wrapper);
                }, 100);
            });
            /* c8 ignore next */
            ro.observe($wrapper[0]);
        }

        // Initial responsive check
        this.handleResponsiveResize($wrapper);
    };

    GravityTable.prototype.setupResponsiveBreakpoints = function ($wrapper) {
        this.breakpoints = {
            mobile: 480,
            tablet: 768
        };

        //console.log('GT Frontend: Responsive breakpoints set:', this.breakpoints);
    };

    GravityTable.prototype.handleResponsiveResize = function ($wrapper) {
        //console.log('GT Frontend: handleResponsiveResize called');
        //console.log('GT Frontend: responsive_mode config:', this.config.responsive_mode);

        if (this.config.responsive_mode === 'collapse') {
            this.initCollapsibleRows($wrapper);
            return;
        }

        if (this.config.responsive_mode === 'modal') {
            this.initModalRows($wrapper);
            return;
        }

        // Flip responsive mode: first column becomes row labels on narrow viewports (#348).
        if (this.config.responsive_mode === 'flip') {
            this.initFlipResponsive($wrapper);
            return;
        }

        if (this.config.responsive_mode !== 'enhanced') {
            //console.log('GT Frontend: Responsive mode not enhanced, staying in table view');
            return;
        }

        var windowWidth = $(window).width();
        var shouldShowCards = false;

        //console.log('GT Frontend: Window width:', windowWidth);
        //console.log('GT Frontend: Mobile breakpoint:', this.breakpoints.mobile);
        //console.log('GT Frontend: Tablet breakpoint:', this.breakpoints.tablet);

        // Determine if we should show cards based on breakpoints and settings
        if (windowWidth <= this.breakpoints.mobile) {
            shouldShowCards = true;
            $wrapper.addClass('mobile-view').removeClass('tablet-view');
            //console.log('GT Frontend: Mobile view detected');
        } else if (windowWidth <= this.breakpoints.tablet) {
            shouldShowCards = this.shouldShowCardsOnTablet();
            $wrapper.addClass('tablet-view').removeClass('mobile-view');
            //console.log('GT Frontend: Tablet view detected, shouldShowCards:', shouldShowCards);
        } else {
            shouldShowCards = false;
            $wrapper.removeClass('mobile-view tablet-view');
            //console.log('GT Frontend: Desktop view detected');
        }

        //console.log('GT Frontend: Final decision - shouldShowCards:', shouldShowCards);

        if (shouldShowCards) {
            this.showCardView($wrapper);
        } else {
            this.showTableView($wrapper);
        }
    };

    /**
     * Flip responsive mode: labels each cell with its column header and makes the first
     * column cell a row-label (role=rowheader). Applies when viewport <= flip_breakpoint (#348).
     */
    GravityTable.prototype.initFlipResponsive = function ($wrapper) {
        if ($wrapper.data('gt-flip-init')) {
            return;
        }
        $wrapper.data('gt-flip-init', true);

        var $table = $wrapper.find('.gt-table');
        var flipBreakpoint = parseInt($wrapper.data('flip-breakpoint')) || 768;
        var headers = [];
        $table.find('thead th').each(function () {
            headers.push($(this).text().trim());
        });

        $table.find('tbody tr').each(function () {
            $(this).find('td').each(function (index) {
                var label = headers[index] || '';
                $(this).attr('data-label', label);
                if (index === 0) {
                    $(this).attr('scope', 'row').attr('role', 'rowheader');
                }
            });
        });

        var applyFlip = function () {
            if ($(window).width() <= flipBreakpoint) {
                $wrapper.addClass('gt-flip-responsive');
            } else {
                /* c8 ignore next */
                $wrapper.removeClass('gt-flip-responsive');
            }
        };
        applyFlip();
        $(window).on('resize.gt-flip.' + $wrapper.attr('id'), applyFlip);
    };

    /**
     * Collapse mode: adds a per-row expand toggle that reveals a sub-row with
     * the values of any columns that overflow on narrow viewports.
     */
    GravityTable.prototype.initCollapsibleRows = function ($wrapper) {
        var self = this;
        var $table = $wrapper.find('.gt-table');
        var $headers = $table.find('thead th');

        $table.find('tbody tr').not('.gt-collapsed-sub-row').each(function () {
            var $row = $(this);
            if ($row.data('gt-collapse-init')) {
                return;
            }
            $row.data('gt-collapse-init', true);

            // Build sub-row data from all cells except the first.
            var $cells = $row.find('td');
            if ($cells.length < 2) {
                return;
            }

            // Create expand toggle in first cell.
            var $toggle = $('<button type="button" class="gt-row-expand-btn" aria-expanded="false" aria-label="' + (self.config.expand_label || 'Show details') + '">&#9658;</button>');
            $cells.first().prepend($toggle);

            // Build a definition list of hidden column data.
            var dlHtml = '<dl class="gt-sub-row-dl">';
            $cells.each(function (idx) {
                if (idx === 0) {
                    return;
                }
                var label = $headers.eq(idx).text().trim();
                var value = $(this).html();
                dlHtml += '<dt>' + label + '</dt><dd>' + value + '</dd>';
            });
            dlHtml += '</dl>';

            var colCount = $cells.length;
            var $subRow = $('<tr class="gt-collapsed-sub-row"><td colspan="' + colCount + '">' + dlHtml + '</td></tr>');
            $row.after($subRow);

            $toggle.on('click', function () {
                var expanded = $toggle.attr('aria-expanded') === 'true';
                $toggle.attr('aria-expanded', expanded ? 'false' : 'true');
                $subRow.toggleClass('gt-sub-row-open', !expanded);
            });
        });
    };

    /**
     * Modal mode: adds a per-row detail button that opens an overlay/drawer
     * showing the full row data as a labelled list.
     */
    GravityTable.prototype.initModalRows = function ($wrapper) {
        var self = this;
        var $table = $wrapper.find('.gt-table');
        var $headers = $table.find('thead th');

        $table.find('tbody tr').each(function () {
            var $row = $(this);
            if ($row.data('gt-modal-init')) {
                return;
            }
            $row.data('gt-modal-init', true);

            var $cells = $row.find('td');
            var $btn = $('<button type="button" class="gt-row-detail-btn" aria-label="' + (self.config.detail_label || 'View row details') + '">&#9432;</button>');
            $cells.last().append($btn);

            $btn.on('click', function () {
                var dlHtml = '<dl class="gt-sub-row-dl">';
                $cells.each(function (idx) {
                    var label = $headers.eq(idx).text().trim();
                    var value = $(this).clone().find('.gt-row-detail-btn').remove().end().html();
                    dlHtml += '<dt>' + label + '</dt><dd>' + value + '</dd>';
                });
                dlHtml += '</dl>';

                var $overlay = $('<div class="gt-row-detail-overlay" role="dialog" aria-modal="true" tabindex="-1">' +
                    '<div class="gt-row-detail-panel">' +
                    '<button type="button" class="gt-row-detail-close" aria-label="Close">&times;</button>' +
                    dlHtml +
                    '</div></div>');

                $overlay.on('click', '.gt-row-detail-close', function () {
                    $overlay.remove();
                });
                $overlay.on('click', function (e) {
                    if ($(e.target).is($overlay)) {
                        $overlay.remove();
                    }
                });

                $('body').append($overlay);
                $overlay.find('.gt-row-detail-close').focus();
            });
        });
    };

    GravityTable.prototype.shouldShowCardsOnTablet = function () {
        // Check if any fields are configured to be hidden on tablet
        if (this.config.responsive_settings) {
            for (var fieldId in this.config.responsive_settings) {
                if (this.config.responsive_settings[fieldId].tablet_visible === false) {
                    return true; // If any field is hidden on tablet, use card view
                }
            }
        }
        return false;
    };

    GravityTable.prototype.showCardView = function ($wrapper) {
        var $table = $wrapper.find('.gt-table');
        var $cardsContainer = $wrapper.find('.gt-cards-container');

        //console.log('GT Frontend: Switching to card view');
        //console.log('GT Frontend: Table element found:', $table.length);
        //console.log('GT Frontend: Cards container found:', $cardsContainer.length);
        //console.log('GT Frontend: Table currently visible:', $table.is(':visible'));
        //console.log('GT Frontend: Cards currently visible:', $cardsContainer.is(':visible'));

        // Hide table, show cards
        $table.hide();
        $cardsContainer.show();

        //console.log('GT Frontend: After toggle - Table visible:', $table.is(':visible'));
        //console.log('GT Frontend: After toggle - Cards visible:', $cardsContainer.is(':visible'));

        // Generate cards from current table data
        this.generateCards($wrapper);
    };

    GravityTable.prototype.showTableView = function ($wrapper) {
        var $table = $wrapper.find('.gt-table');
        var $cardsContainer = $wrapper.find('.gt-cards-container');

        //console.log('GT Frontend: Switching to table view');
        //console.log('GT Frontend: Table element found:', $table.length);
        //console.log('GT Frontend: Cards container found:', $cardsContainer.length);

        // Show table, hide cards
        $table.show();
        $cardsContainer.hide();

        //console.log('GT Frontend: After toggle - Table visible:', $table.is(':visible'));
        //console.log('GT Frontend: After toggle - Cards visible:', $cardsContainer.is(':visible'));
    };

    GravityTable.prototype.generateCards = function ($wrapper) {
        var self = this;
        var $cardsContainer = $wrapper.find('.gt-cards-container');
        var $table = $wrapper.find('.gt-table');
        var $tbody = $table.find('tbody');
        var isTabletView = $wrapper.hasClass('tablet-view');

        //console.log('GT Frontend: Generating cards from table data');
        //console.log('GT Frontend: Responsive settings:', this.config.responsive_settings);
        //console.log('GT Frontend: Is tablet view:', isTabletView);

        // Clear existing cards
        $cardsContainer.empty();

        // Check if table has data
        if ($tbody.find('tr').length === 0 || $tbody.find('.gt-loading-row').length > 0) {
            $cardsContainer.html('<div class="gt-cards-loading">Loading cards...</div>');
            return;
        }

        // Check if table has no entries
        if ($tbody.find('.gt-no-entries').length > 0) {
            $cardsContainer.html(
                '<div class="gt-cards-empty">' +
                '<div class="gt-empty-icon">📄</div>' +
                '<div class="gt-empty-message">No entries found</div>' +
                '<div class="gt-empty-submessage">Try adjusting your search or filter criteria</div>' +
                '</div>'
            );
            return;
        }

        // Generate cards from table rows
        $tbody.find('tr:not(.gt-loading-row)').each(function (index) {
            var $row = $(this);
            var entryId = $row.data('entry-id') || (index + 1);

            var cardHtml = self.generateCardHtml($row, entryId, isTabletView);
            $cardsContainer.append(cardHtml);
        });

        // Bind card events
        this.bindCardEvents($wrapper);

        //console.log('GT Frontend: Generated', $cardsContainer.find('.gt-entry-card').length, 'cards');
    };

    GravityTable.prototype.generateCardHtml = function ($row, entryId, isTabletView) {
        var self = this;
        var cardHtml = '<div class="gt-entry-card" data-entry-id="' + entryId + '">';

        // Process each table cell to create card fields
        var $cells = $row.find('td:not(.gt-checkbox-cell):not(.gt-actions-column)');
        var $headers = $row.closest('table').find('thead th:not(.gt-selection-header):not(.gt-actions-header)');

        // Find date created value for the header - prioritize date_created field or first date type field
        var dateCreatedValue = '';
        var dateFieldFound = false;

        // Debug logging
        //console.log('GT Mobile Debug: Processing entry', entryId, 'with', $cells.length, 'cells and', $headers.length, 'headers');

        $cells.each(function (index) {
            if (dateFieldFound) return; // Skip if we already found a date

            var $header = $($headers[index]);
            var fieldId = $header.data('field-id') || $header.attr('data-field-id') || $header.attr('data-field') || $header.attr('data-sort-field');
            var cellValue = $(this).text().trim();
            var headerText = $header.find('.gt-header-label').text() || $header.text();

            // Debug each field (#920: removed verbose console.log spam).
            // console.log('GT Mobile Debug: Field', index, {
            //     fieldId: fieldId,
            //     cellValue: cellValue,
            //     headerText: headerText,
            //     columnConfig: self.config.column_config ? self.config.column_config[fieldId] : 'no config'
            // });

            if (!fieldId) {
                //console.log('GT Mobile: No field ID found for header index', index);
                return; // Continue to next iteration
            }

            // Check if this is a date field using column config or field ID
            var isDateField = false;

            // Method 1: Check column config for date type
            if (self.config.column_config && self.config.column_config[fieldId]) {
                if (self.config.column_config[fieldId].type === 'date' || fieldId === 'date_created') {
                    isDateField = true;
                    //console.log('GT Mobile Debug: Found date field via column config:', fieldId);
                }
            }

            // Method 2: Check for known date field IDs
            if (!isDateField && (fieldId === 'date_created' || fieldId == '1')) {
                isDateField = true;
                //console.log('GT Mobile Debug: Found date field via field ID:', fieldId);
            }

            // Method 3: Check header text as fallback
            if (!isDateField) {
                if (headerText.toLowerCase().includes('date') ||
                    headerText.toLowerCase() === 'load date') {
                    isDateField = true;
                    //console.log('GT Mobile Debug: Found date field via header text:', headerText);
                }
            }

            // If this is a date field, get its value
            if (isDateField) {
                //console.log('GT Mobile Debug: Processing date field:', fieldId, 'with value:', cellValue);
                if (cellValue && cellValue !== '' && cellValue !== '&nbsp;' && cellValue !== 'N/A') {
                    dateCreatedValue = cellValue;
                    dateFieldFound = true;
                    //console.log('GT Mobile Debug: Using date value:', dateCreatedValue);
                    return false; // Break loop
                } else {
                    //console.log('GT Mobile Debug: Date field found but value is empty/invalid:', cellValue);
                }
            }
        });

        //console.log('GT Mobile Debug: Final date value for entry', entryId, ':', dateCreatedValue || 'No Date');

        // Fallback: If no date found through proper methods, try to get the first non-empty cell value
        if (!dateCreatedValue && $cells.length > 0) {
            //console.log('GT Mobile Debug: No date found, trying fallback with first cells');
            $cells.each(function (index) {
                if (!dateCreatedValue) {
                    var fallbackValue = $(this).text().trim();
                    //console.log('GT Mobile Debug: Fallback cell', index, 'value:', fallbackValue);
                    // Check if this looks like a date (contains numbers and slashes/dashes)
                    if (fallbackValue && fallbackValue.match(/\d+[\/\-]\d+[\/\-]\d+/) && fallbackValue !== '&nbsp;') {
                        dateCreatedValue = fallbackValue;
                        //console.log('GT Mobile Debug: Using fallback date value:', dateCreatedValue);
                        return false;
                    }
                }
            });
        }

        // Final fallback - use entry ID as identifier if still no date
        var displayDate = dateCreatedValue || ('Entry #' + entryId);
        //console.log('GT Mobile Debug: Final display date for entry', entryId, ':', displayDate);

        // Card header with date as primary info and collapse toggle
        cardHtml += '<div class="gt-card-header">';
        cardHtml += '<div class="gt-card-primary-info">';
        cardHtml += '<div class="gt-card-date">' + displayDate + '</div>';
        cardHtml += '<div class="gt-card-entry-id">Entry #' + entryId + '</div>';
        cardHtml += '</div>';
        cardHtml += '<div class="gt-card-actions">';

        // Add checkbox if selection is enabled
        var $checkbox = $row.find('.gt-entry-checkbox');
        if ($checkbox.length > 0) {
            cardHtml += '<input type="checkbox" class="gt-entry-checkbox" value="' + entryId + '"';
            if ($checkbox.is(':checked')) {
                cardHtml += ' checked';
            }
            cardHtml += '>';
        }

        // Add main collapse toggle for entire card
        cardHtml += '<button class="gt-card-main-toggle" title="Expand/Collapse Card">+</button>';
        cardHtml += '</div></div>';

        // Card content (collapsed by default)
        cardHtml += '<div class="gt-card-main-content collapsed">';

        var visibleFields = '';
        var hiddenFields = '';
        var hasHiddenFields = false;

        $cells.each(function (index) {
            var $cell = $(this);
            var $header = $($headers[index]);
            var fieldId = $header.data('field-id') || $header.attr('data-field') || index;
            var fieldLabel = $header.text().trim().replace(/[▲▼]/g, '').trim() || 'Field ' + (index + 1);
            var fieldValue = $cell.html().trim() || '';
            var fieldType = $header.data('field-type') || 'text';

            // Get responsive settings for this field
            var isVisibleOnDevice = self.isFieldVisibleOnCurrentDevice(fieldId, isTabletView);
            var mobileLabel = self.getMobileLabel(fieldId, fieldLabel);

            //console.log('GT Frontend: Field', fieldId, '(' + fieldLabel + ') - visible on device:', isVisibleOnDevice);

            // Create field HTML
            var fieldHtml = '<div class="gt-card-field" data-field-id="' + fieldId + '" data-field-type="' + fieldType + '">';
            fieldHtml += '<div class="gt-card-field-label">' + mobileLabel + '</div>';
            fieldHtml += '<div class="gt-card-field-value';

            // Preserve editing classes
            if ($cell.hasClass('gt-editable-cell')) {
                fieldHtml += ' gt-editable-cell';
                // Add gt-empty-field class for empty editable cells
                if ($cell.hasClass('gt-empty-field')) {
                    fieldHtml += ' gt-empty-field';
                }
            }
            if ($cell.hasClass('gt-readonly-cell')) {
                fieldHtml += ' gt-readonly-cell';
            }

            fieldHtml += '">' + fieldValue + '</div>';
            fieldHtml += '</div>';

            // Separate into visible and hidden sections based on admin settings
            // isVisibleOnDevice = true means field is CHECKED (mobile_visible: true) in admin → show immediately
            // isVisibleOnDevice = false means field is UNCHECKED (mobile_visible: false) in admin → show only when expanded
            if (isVisibleOnDevice) {
                visibleFields += fieldHtml;
                //console.log('GT Frontend: Adding', fieldLabel, 'to visible fields (checked in admin)');
            } else {
                hiddenFields += fieldHtml;
                hasHiddenFields = true;
                //console.log('GT Frontend: Adding', fieldLabel, 'to hidden fields (unchecked in admin)');
            }
        });

        // Add visible fields section (always shown)
        if (visibleFields) {
            cardHtml += '<div class="gt-card-visible-fields">' + visibleFields + '</div>';
        }

        // Add expand/collapse toggle if there are hidden fields
        if (hasHiddenFields) {
            cardHtml += '<div class="gt-card-toggle-section">';
            cardHtml += '<button class="gt-card-toggle" title="Show/Hide Additional Fields">';
            cardHtml += '<span class="gt-toggle-text">Show More</span>';
            cardHtml += '<span class="gt-toggle-icon">▼</span>';
            cardHtml += '</button>';
            cardHtml += '</div>';

            // Add hidden fields section (collapsed by default)
            cardHtml += '<div class="gt-card-hidden-fields collapsed">' + hiddenFields + '</div>';
        }

        // Close main content div
        cardHtml += '</div>';

        // Close main card div
        cardHtml += '</div>';

        return cardHtml;
    };

    // Responsive-visibility helpers (isFieldVisibleInCards, isFieldVisibleOnMobile,
    // isFieldVisibleOnTablet, getMobileLabel, isFieldVisibleOnCurrentDevice)
    // moved to assets/js/frontend/responsive-visibility.js (#832 slice 12).

    GravityTable.prototype.updateCardsAfterDataChange = function ($wrapper) {
        var $cardsContainer = $wrapper.find('.gt-cards-container');

        // Only update cards if they're currently visible
        if ($cardsContainer.is(':visible')) {
            //console.log('GT Frontend: Updating cards after data change');
            this.generateCards($wrapper);
        }
    };

    GravityTable.prototype.bindCardEvents = function ($wrapper) {
        var self = this;

        // Handle main card toggle (expand/collapse entire card)
        $wrapper.off('click.cardMainToggle').on('click.cardMainToggle', '.gt-card-main-toggle', function () {
            var $toggle = $(this);
            var $card = $toggle.closest('.gt-entry-card');
            var $mainContent = $card.find('.gt-card-main-content');

            if ($mainContent.hasClass('collapsed')) {
                // Expand entire card
                $mainContent.removeClass('collapsed');
                $toggle.text('-').addClass('expanded');
            } else {
                // Collapse entire card
                $mainContent.addClass('collapsed');
                $toggle.text('+').removeClass('expanded');
            }
        });

        // Handle "Show More" toggle (expand/collapse hidden fields within visible card)
        $wrapper.off('click.cardToggle').on('click.cardToggle', '.gt-card-toggle', function () {
            var $toggle = $(this);
            var $card = $toggle.closest('.gt-entry-card');
            var $hiddenFields = $card.find('.gt-card-hidden-fields');
            var $toggleText = $toggle.find('.gt-toggle-text');
            var $toggleIcon = $toggle.find('.gt-toggle-icon');

            if ($hiddenFields.hasClass('collapsed')) {
                // Expand to show hidden fields
                $hiddenFields.removeClass('collapsed');
                $toggleText.text('Show Less');
                $toggleIcon.html('▲');
                $toggle.addClass('expanded');
            } else {
                // Collapse to hide fields
                $hiddenFields.addClass('collapsed');
                $toggleText.text('Show More');
                $toggleIcon.html('▼');
                $toggle.removeClass('expanded');
            }
        });

        // Handle card checkbox changes (sync with table)
        $wrapper.off('change.cardCheckbox').on('change.cardCheckbox', '.gt-entry-card .gt-entry-checkbox', function () {
            var $checkbox = $(this);
            var entryId = $checkbox.closest('.gt-entry-card').data('entry-id');
            var $tableCheckbox = $wrapper.find('.gt-table tbody tr[data-entry-id="' + entryId + '"] .gt-entry-checkbox');

            if ($tableCheckbox.length > 0) {
                $tableCheckbox.prop('checked', $checkbox.is(':checked'));
            }

            // Trigger table checkbox change event for bulk actions
            $tableCheckbox.trigger('change');
        });

        // Handle editable field clicks in cards (delegate to table functionality)
        $wrapper.off('click.cardEdit').on('click.cardEdit', '.gt-card-field-value.gt-editable-cell', function () {
            var $fieldValue = $(this);
            var $card = $fieldValue.closest('.gt-entry-card');
            var entryId = $card.data('entry-id');
            var fieldId = $fieldValue.closest('.gt-card-field').data('field-id');

            // Find corresponding table cell and trigger click
            var $tableCell = $wrapper.find('.gt-table tbody tr[data-entry-id="' + entryId + '"] td[data-field-id="' + fieldId + '"]');
            if ($tableCell.length > 0) {
                $tableCell.trigger('click');
            }
        });
    };

})(window);

/* ===== assets/js/frontend/edit-history.js ===== */
/**
 * TableCrafter — frontend/edit-history.js
 *
 * Undo / Redo history for inline cell edits. First slice under #833
 * (which itself is a child of the #830 split of frontend.js).
 *
 * Nine helpers attached directly to GravityTable.prototype:
 *
 *   - initUndoRedo()              — wire toolbar buttons, keyboard
 *                                   shortcuts, initial state. Called
 *                                   from init() in frontend.js.
 *   - pushHistoryEntry(entry)     — push to undo stack, evict oldest
 *                                   past _undoLimit, clear redo stack.
 *                                   Called by saveField after each
 *                                   successful edit (unless
 *                                   _suppressHistory is true).
 *   - updateUndoRedoButtons()     — sync disabled state of both buttons
 *                                   to current stack contents.
 *   - undoLastEdit()              — pop undo, push to redo, replay with
 *                                   oldValue, show toast.
 *   - redoLastEdit()              — pop redo, push to undo, replay with
 *                                   newValue, show toast.
 *   - replayHistoryEntry(entry, useNew)
 *                                 — re-issue saveField for the entry's
 *                                   cell with _suppressHistory set, so
 *                                   the replay doesn't re-enter the
 *                                   stack. Skips silently if row is no
 *                                   longer in the DOM (#535).
 *   - getFieldLabel(fieldId)      — resolve a human-readable label for
 *                                   the field used in toast messages.
 *                                   Reads config.column_config, falls
 *                                   back to the <th> text, then to
 *                                   "Field N".
 *   - shortValue(v)               — truncate a value for toast display
 *                                   (40 chars, ellipsis, "(empty)" for
 *                                   empty string / null).
 *   - showUndoToast(msg)          — render a transient aria-live toast.
 *
 * Listed as separate prototype assignments (not Object.assign) so the
 * pre-#831 file-grep contracts in our PHP test suite — which look for
 * `GravityTable.prototype.X = function` literals — keep working as the
 * #833 split progresses.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    GravityTable.prototype.initUndoRedo = function () {
        var self = this;
        var $wrapper = $('#' + this.wrapperId);

        this._undoStack = [];
        this._redoStack = [];
        this._undoLimit = 20;
        this._suppressHistory = false;

        // Inject Undo / Redo buttons into the table controls
        var $controls = $wrapper.find('.gt-table-controls').first();
        if ($controls.length && !$controls.find('.gt-undo-btn').length) {
            $controls.append(
                '<button type="button" class="gt-undo-btn" disabled title="Undo last edit (Cmd/Ctrl+Z)">↶ Undo</button>' +
                '<button type="button" class="gt-redo-btn" disabled title="Redo (Cmd/Ctrl+Shift+Z)">↷ Redo</button>'
            );
        }

        $wrapper.on('click', '.gt-undo-btn', function (e) { e.preventDefault(); self.undoLastEdit(); });
        $wrapper.on('click', '.gt-redo-btn', function (e) { e.preventDefault(); self.redoLastEdit(); });

        // Global keybinds (only fire when focus is inside this wrapper or its modal)
        $(document).on('keydown.gtUndo' + this.wrapperId, function (e) {
            var key = e.key || '';
            var isMod = e.metaKey || e.ctrlKey;
            if (!isMod) return;
            if (key !== 'z' && key !== 'Z' && key !== 'y' && key !== 'Y') return;

            // Only handle if the active element is inside this table's wrapper
            // and is NOT an editable input (so native browser undo still works
            // while typing inside a cell editor).
            var active = document.activeElement;
            if (!active) return;
            var inWrapper = $wrapper[0].contains(active);
            if (!inWrapper) return;
            if ($(active).is('input, textarea, select')) return;
            // #535: also skip elements the page has marked as editable. The
            // browser's own undo already operates on those — capturing here
            // would double-fire (our stack pop + the browser's own undo) and
            // the cell value drifts out of sync with what the user sees.
            // Using the browser-native HTMLElement.isContentEditable property
            // is more correct than a selector check: it returns true when the
            // element OR any ancestor has contenteditable="true", and false
            // when contenteditable="false" explicitly opts out — exactly the
            // semantics we want. (gravity-tables itself never turns this on;
            // the inline editor uses real <input> elements, see #433.)
            if (active.isContentEditable) return;

            if (key === 'z' || key === 'Z') {
                e.preventDefault();
                if (e.shiftKey) self.redoLastEdit();
                else self.undoLastEdit();
            } else if (key === 'y' || key === 'Y') {
                e.preventDefault();
                self.redoLastEdit();
            }
        });

        // Clear stacks on entry reload (paging, filters, etc.) so undo can't
        // target stale rows.
        this.updateUndoRedoButtons();
    };

    GravityTable.prototype.pushHistoryEntry = function (entry) {
        this._undoStack.push(entry);
        if (this._undoStack.length > this._undoLimit) this._undoStack.shift();
        this._redoStack = []; // any new edit invalidates the redo path
        this.updateUndoRedoButtons();
    };

    GravityTable.prototype.updateUndoRedoButtons = function () {
        var $wrapper = $('#' + this.wrapperId);
        $wrapper.find('.gt-undo-btn').prop('disabled', this._undoStack.length === 0);
        $wrapper.find('.gt-redo-btn').prop('disabled', this._redoStack.length === 0);
    };

    GravityTable.prototype.undoLastEdit = function () {
        if (!this._undoStack.length) return;
        var entry = this._undoStack.pop();
        this._redoStack.push(entry);
        this.replayHistoryEntry(entry, /* useNew */ false);
        this.showUndoToast('Undone: ' + entry.fieldLabel + ' reverted to "' + this.shortValue(entry.oldValue) + '"');
        this.updateUndoRedoButtons();
    };

    GravityTable.prototype.redoLastEdit = function () {
        if (!this._redoStack.length) return;
        var entry = this._redoStack.pop();
        this._undoStack.push(entry);
        this.replayHistoryEntry(entry, /* useNew */ true);
        this.showUndoToast('Redone: ' + entry.fieldLabel + ' set to "' + this.shortValue(entry.newValue) + '"');
        this.updateUndoRedoButtons();
    };

    GravityTable.prototype.replayHistoryEntry = function (entry, useNew) {
        var self = this;
        var $wrapper = $('#' + this.wrapperId);
        var $cell = $wrapper.find('td.gt-editable-cell[data-entry-id="' + entry.entryId + '"][data-field-id="' + entry.fieldId + '"]').first();
        if ( ! $cell.length ) {
            // Stale reference (#535): the row this entry pointed at is no longer
            // in the DOM — deleted, paginated away, or filtered out. Earlier
            // versions fell back to a detached placeholder and called saveField()
            // anyway, which could resurrect a deleted entry server-side or
            // surface as an "Undefined index" / 500 in the user's face. Skip
            // silently, surface a toast so the user knows why nothing visibly
            // changed, and do NOT re-push to either stack.
            self.showUndoToast('Cannot ' + (useNew ? 'redo' : 'undo') + ': row no longer in view');
            return;
        }
        // Mirror what saveField expects: original-value lets keyboard nav re-edit,
        // but we don't push to history during replay.
        $cell.data('original-value', useNew ? entry.oldValue : entry.newValue);
        self._suppressHistory = true;
        self.saveField(entry.entryId, entry.fieldId, useNew ? entry.newValue : entry.oldValue, $cell);
        setTimeout(function () { self._suppressHistory = false; }, 50);
    };

    GravityTable.prototype.getFieldLabel = function (fieldId) {
        var cfg = this.config && this.config.column_config ? this.config.column_config[fieldId] : null;
        if (cfg && cfg.label) return cfg.label;
        var $wrapper = $('#' + this.wrapperId);
        var $th = $wrapper.find('thead th.gt-column-' + String(fieldId).replace(/[^A-Za-z0-9_-]/g, '')).first();
        return $th.length ? $th.text().trim().replace(/▲|▼|\s+/g, ' ').trim() : ('Field ' + fieldId);
    };

    GravityTable.prototype.shortValue = function (v) {
        v = (v == null) ? '' : String(v);
        if (v === '') return '(empty)';
        if (v.length > 40) return v.slice(0, 38) + '…';
        return v;
    };

    GravityTable.prototype.showUndoToast = function (msg) {
        var $existing = $('.gt-undo-toast');
        if ($existing.length) $existing.remove();
        var $t = $('<div class="gt-undo-toast" role="status" aria-live="polite"></div>').text(msg);
        $('body').append($t);
        setTimeout(function () { $t.addClass('gt-undo-toast-shown'); }, 10);
        setTimeout(function () {
            $t.removeClass('gt-undo-toast-shown');
            setTimeout(function () { $t.remove(); }, 300);
        }, 2200);
    };

})(window);

/* ===== assets/js/frontend/edit-validation.js ===== */
/**
 * TableCrafter — frontend/edit-validation.js
 *
 * Per-column inline-edit validation rules (#1742, Pro).
 *
 * Public surface (GravityTable.prototype):
 *
 *   validateCell(fieldId, value) → {valid: bool, message: string}
 *     Checks value against this.config.column_validations[fieldId].
 *     Returns {valid:true, message:''} when no rules are configured.
 *
 *   showValidationError($field, $input, message)
 *     Renders an inline error message inside $field below the input,
 *     adds .gt-input-error to $input, and removes any previous error.
 *
 * Consumed by edit-field.js before every saveField() call:
 *   var v = self.validateCell(fieldId, newValue);
 *   if (!v.valid) { self.showValidationError($field, $input, v.message); return; }
 *   self.saveField(entryId, fieldId, newValue, $field);
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    /**
     * Validate a cell value against column_validations config.
     *
     * @param {string|number} fieldId
     * @param {string} value
     * @returns {{valid: boolean, message: string}}
     */
    /**
     * Validate a cell value against column_validations config.
     *
     * @param {string|number} fieldId
     * @param {string} value
     * @param {number|string} [entryId]  Current entry ID — used by the unique rule to exclude
     *                                   the row being edited from the duplicate scan.
     * @returns {{valid: boolean, message: string}}
     */
    GravityTable.prototype.validateCell = function (fieldId, value, entryId) {
        var config = this.config || {};
        var validations = config.column_validations;
        if (!validations || typeof validations !== 'object') {
            return { valid: true, message: '' };
        }
        var rules = validations[String(fieldId)];
        if (!rules || typeof rules !== 'object') {
            return { valid: true, message: '' };
        }

        var str = String(value);

        // required
        if (rules.required && str.trim() === '') {
            return { valid: false, message: 'This field is required.' };
        }

        // min_length
        if (rules.min_length > 0 && str.length < rules.min_length) {
            return { valid: false, message: 'Minimum ' + rules.min_length + ' characters required.' };
        }

        // max_length
        if (rules.max_length > 0 && str.length > rules.max_length) {
            return { valid: false, message: 'Maximum ' + rules.max_length + ' characters allowed.' };
        }

        // regex
        if (rules.regex) {
            try {
                var re = new RegExp(rules.regex, 'u');
                if (!re.test(str)) {
                    return { valid: false, message: rules.regex_message || 'Invalid format.' };
                }
            } catch (e) {
                // invalid pattern — silently skip
            }
        }

        // min_value (numeric strings only)
        if (rules.min_value !== undefined && rules.min_value !== null && !isNaN(parseFloat(str)) && isFinite(str)) {
            if (parseFloat(str) < parseFloat(rules.min_value)) {
                return { valid: false, message: 'Minimum value is ' + rules.min_value + '.' };
            }
        }

        // max_value (numeric strings only)
        if (rules.max_value !== undefined && rules.max_value !== null && !isNaN(parseFloat(str)) && isFinite(str)) {
            if (parseFloat(str) > parseFloat(rules.max_value)) {
                return { valid: false, message: 'Maximum value is ' + rules.max_value + '.' };
            }
        }

        // #2282 — oneOf: value must be in the allowed list (strict string comparison).
        // An empty oneOf array means no restriction (skip the rule).
        if (rules.oneOf && Array.isArray(rules.oneOf) && rules.oneOf.length > 0) {
            if (rules.oneOf.indexOf(str) === -1) {
                return { valid: false, message: 'Value is not one of the allowed options.' };
            }
        }

        // #2282 — notOneOf: value must NOT be in the blocked list (strict string comparison).
        if (rules.notOneOf && Array.isArray(rules.notOneOf) && rules.notOneOf.length > 0) {
            if (rules.notOneOf.indexOf(str) !== -1) {
                return { valid: false, message: 'Value is not allowed.' };
            }
        }

        // #2282 — phone validation: dual mode — permissive or E.164.
        if (rules.phone) {
            if (rules.phone === 'permissive') {
                // Permissive: strip formatting chars (spaces, parens, dots, +, dashes),
                // then require 7–15 remaining digit characters.
                var stripped = str.replace(/[\s().+\-]/g, '');
                if (!/^\d{7,15}$/.test(stripped)) {
                    return { valid: false, message: 'Please enter a valid phone number.' };
                }
            } else {
                // E.164 mode: optional + prefix, first significant digit [1-9], 1–14 more digits.
                if (!/^\+?[1-9]\d{1,14}$/.test(str)) {
                    return { valid: false, message: 'Please enter a valid phone number (E.164 format).' };
                }
            }
        }

        // #2282 — unique: client-side pre-check by scanning this.config.table_data.
        //
        // In non-SSP mode, when table_data is available (pre-loaded rows array), scan
        // it synchronously to catch obvious duplicates before the AJAX save round-trip.
        // The server enforces the authoritative unique constraint via GFAPI::get_entries
        // or via the gt_check_unique endpoint (used for SSP and any missed client cases).
        //
        // Skip in server-side processing (SSP) mode — all rows are not loaded locally.
        if (rules.unique) {
            var tableData = config.table_data;
            var isSSP = config.processing_mode === 'server';
            if (!isSSP && tableData && Array.isArray(tableData)) {
                var currentEntryId = entryId !== undefined ? String(entryId) : null;
                var fieldIdStr = String(fieldId);
                for (var i = 0; i < tableData.length; i++) {
                    var row = tableData[i];
                    if (currentEntryId !== null && String(row.entry_id) === currentEntryId) {
                        continue; // exclude current entry from duplicate check
                    }
                    if (String(row[fieldIdStr] || '') === str) {
                        return { valid: false, message: 'This value must be unique.' };
                    }
                }
            }
            // SSP mode or no table_data — skip client-side check; server enforces.
        }

        return { valid: true, message: '' };
    };

    /**
     * Show an inline validation error message in the editing cell.
     * Replaces any previously shown error (only one error at a time).
     *
     * @param {jQuery} $field  The td/span being edited.
     * @param {jQuery} $input  The .gt-edit-input inside $field.
     * @param {string} message Human-readable error text.
     */
    GravityTable.prototype.showValidationError = function ($field, $input, message) {
        var fieldEl = ($field && $field[0]) ? $field[0] : $field;
        var inputEl = ($input && $input[0]) ? $input[0] : $input;

        // Remove any existing error
        var prev = fieldEl.querySelectorAll('.gt-validation-error');
        for (var i = 0; i < prev.length; i++) { prev[i].parentNode.removeChild(prev[i]); }

        if (inputEl) { inputEl.classList.add('gt-input-error'); }

        var span = document.createElement('span');
        span.className = 'gt-validation-error';
        span.style.color = '#dc2626';
        span.style.fontSize = '0.78em';
        span.style.display = 'block';
        span.style.marginTop = '2px';
        span.textContent = message;
        fieldEl.appendChild(span);
    };

})(window);

/* ===== assets/js/frontend/edit-save.js ===== */
/**
 * TableCrafter — frontend/edit-save.js
 *
 * Inline cell-edit AJAX save. Second slice under #833 / #889.
 * Pairs with edit-history.js (slice 1) — saveField calls into
 * pushHistoryEntry + getFieldLabel via this/self.
 *
 * Public surface (attached directly to GravityTable.prototype):
 *
 *   - saveField(entryId, fieldId, value, $field, displayValue)
 *
 * Behaviors preserved verbatim from the pre-#833 monolith:
 *
 *   - History interop: when `_suppressHistory` is false and
 *     oldValue !== value, push a history entry via this.pushHistoryEntry
 *     (defined in edit-history.js).
 *   - Optimistic UI: swap the cell content to "Saving..." immediately.
 *   - AJAX payload: action=gt_update_entry, nonce, entry_id, table_id,
 *     updates[fieldId] = value.
 *   - WAF envelope (#553): when this.config.waf_safe_payload is on,
 *     wrap entry_id + table_id + updates into a base64-encoded `payload`
 *     field so generic WAF rules don't false-positive on cell content.
 *   - Debug flag: when `?gt_debug=1` is on the URL, append `debug=true`.
 *   - Success: render escaped displayValue (or value), restore any
 *     `original-padding` data on the cell, fan out updated calc fields
 *     into sibling cells with a transient `gt-recalculated` highlight,
 *     reapply conditional formatting, refresh column totals if enabled.
 *   - Server-side error: alert the response.data message, restore the
 *     cell to its original value, restore padding.
 *   - Network failure: alert the network error, restore the cell value.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    GravityTable.prototype.saveField = function (entryId, fieldId, value, $field, displayValue) {
        var self = this;

        // Track this edit on the undo stack unless we're replaying from undo/redo
        if (!self._suppressHistory) {
            var oldValue = $field.data('original-value');
            if (oldValue === undefined) oldValue = '';
            // Skip pushing no-ops
            if (String(oldValue) !== String(value)) {
                self.pushHistoryEntry({
                    entryId: String(entryId),
                    fieldId: String(fieldId),
                    oldValue: String(oldValue),
                    newValue: String(value),
                    fieldLabel: self.getFieldLabel(fieldId)
                });
            }
        }

        // Show saving state
        $field.removeClass('gt-editing');
        $field.html('Saving...');

        var data = {
            action: 'gt_update_entry',
            nonce: this.config.nonce,
            entry_id: entryId,
            table_id: this.config.table_id || 0,
            updates: {}
        };
        data.updates[fieldId] = value;

        // #553 slice 3 — WAF-safe payload client-side encode. When the
        // self.config.waf_safe_payload flag is on (driven by the
        // gt_waf_safe_payload_enabled filter on the server side), wrap entry_id
        // + updates into an opaque base64 envelope under `payload` so generic
        // WAF rules (Cloudflare/Sucuri/mod_security/Wordfence) can't pattern-
        // match SQLi/XSS tokens against legitimate cell content. Server (slice
        // 2 v4.41.0) accepts both shapes — encoded or legacy form-encoded —
        // and falls through cleanly when the envelope is absent.
        if (this.config && this.config.waf_safe_payload) {
            var encoded = btoa(JSON.stringify({
                entry_id: data.entry_id,
                table_id: data.table_id,
                updates: data.updates
            }));
            data = {
                action: 'gt_update_entry',
                nonce: this.config.nonce,
                payload: encoded
            };
        }

        // Enable debugging if URL contains debug parameter
        if (window.location.href.indexOf('gt_debug=1') !== -1) {
            /* c8 ignore next */
            data.debug = 'true';
        }

        //console.log('GT Frontend: Saving field data:', data);

        $.post(this.config.ajax_url, data, function (response) {
            //console.log('GT Frontend: Save field response:', response);
            if (response.success) {
                // For lookup fields, show the display value instead of the stored ID
                var finalDisplayValue = displayValue || value;

                // Special handling for empty values - show empty cell instead of cached old values
                if (value === '' || value === null) {
                    finalDisplayValue = '';
                }

                $field.html(self.escapeHtml(finalDisplayValue));

                // #1749 — show diff badge if value changed.
                if (typeof self.showDiffBadge === 'function') {
                    var preEditValue = $field.data('original-value');
                    self.showDiffBadge($field[0], String(preEditValue !== undefined ? preEditValue : ''), String(finalDisplayValue));
                }

                // Restore original padding if it was a cell-based edit
                if ($field.hasClass('gt-editable-cell') && $field.data('original-padding')) {
                    $field.css('padding', $field.data('original-padding'));
                    $field.removeData('original-padding');
                }

                // Update any recalculated calculation fields in the same row
                if (response.data && response.data.updated_fields) {
                    var $row = $field.closest('tr');
                    $.each(response.data.updated_fields, function (recalcFieldId, recalcValue) {
                        // Skip the field we just edited (already updated above).
                        // #893: object keys from $.each are always strings, so
                        // coerce both sides before compare. Without this, a
                        // caller passing fieldId as a Number would see the
                        // just-rendered cell clobbered by the echoed value.
                        if (String(recalcFieldId) === String(fieldId)) {
                            return;
                        }
                        var $calcCell = $row.find('td[data-field-id="' + recalcFieldId + '"]');
                        if ($calcCell.length) {
                            $calcCell.html(self.escapeHtml(recalcValue));
                            // Brief highlight animation to show the cell was updated
                            $calcCell.addClass('gt-recalculated');
                            setTimeout(function () {
                                $calcCell.removeClass('gt-recalculated');
                            }, 1500);
                        }
                    });
                }

                // Reapply conditional formatting to updated cell
                self.applyConditionalFormatting();

                // Refresh column totals after edit
                if (self.config.show_column_totals) {
                    self.updateColumnTotals();
                }

                //console.log('GT Frontend: Field saved successfully');
            } else {
                console.error('GT Frontend: Save field error:', response.data);
                $field.html(self.escapeHtml($field.data('original-value')));

                // Restore original padding on error too
                if ($field.hasClass('gt-editable-cell') && $field.data('original-padding')) {
                    $field.css('padding', $field.data('original-padding'));
                    $field.removeData('original-padding');
                }

                alert('Error saving field: ' + response.data);
            }
        }).fail(function (xhr, status, error) {
            console.error('GT Frontend: Save field AJAX failed:', xhr, status, error);
            console.error('GT Frontend: Response text:', xhr.responseText);
            $field.html(self.escapeHtml($field.data('original-value')));
            alert('Error saving field: ' + error);
        });
    };

})(window);

/* ===== assets/js/frontend/edit-keyboard-nav.js ===== */
/**
 * TableCrafter — frontend/edit-keyboard-nav.js
 *
 * Keyboard navigation between inline-editable cells. Third slice
 * under #833. Two helpers, ~80 lines.
 *
 * Public surface (attached directly to GravityTable.prototype):
 *
 *   - findNextEditCell($field, direction)
 *       Returns { entryId, fieldId } descriptor for the next editable
 *       cell to focus, or null if no candidate exists. Direction is
 *       'next', 'prev', or 'down':
 *         - 'down': same column on the next row that has an editable
 *                   cell with the same data-field-id.
 *         - 'next': next editable cell on the current row; if at end,
 *                   wrap to the first editable cell of the next row.
 *         - 'prev': previous editable cell on the current row; if at
 *                   start, wrap to the last editable cell of the
 *                   previous row.
 *
 *   - scheduleEditOnTarget(target)
 *       Wait 250ms (for the in-flight saveField to settle) then enter
 *       edit mode on the target cell via this.editField. No-op when
 *       the target descriptor is null or the matching cell is gone.
 *
 * Callers: the editField keydown handlers in frontend.js. Both methods
 * route through this/self so they survive extraction.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    /**
     * Given the cell currently being edited, return a descriptor for the
     * next/prev editable cell to focus next. Returns { entryId, fieldId }
     * or null if no candidate exists. Direction is 'next', 'prev', or 'down'.
     */
    GravityTable.prototype.findNextEditCell = function ($field, direction) {
        var $wrapper = $('#' + this.wrapperId);
        var $row = $field.closest('tr');
        if (!$row.length) return null;

        var $cells = $row.find('td.gt-editable-cell');
        if (!$cells.length) return null;
        var idx = $cells.index($field);

        function descriptorFromCell($c) {
            if (!$c || !$c.length) return null;
            var fid = $c.attr('data-field-id');
            var eid = $c.attr('data-entry-id') || $c.closest('tr').attr('data-entry-id');
            if (!fid || !eid) return null;
            return { entryId: String(eid), fieldId: String(fid) };
        }

        if (direction === 'down') {
            var fid = $field.attr('data-field-id');
            if (!fid) return null;
            var $next = $row.next('tr');
            while ($next.length) {
                var $cell = $next.find('td.gt-editable-cell[data-field-id="' + fid + '"]').first();
                if ($cell.length) return descriptorFromCell($cell);
                $next = $next.next('tr');
            }
            return null;
        }

        if (direction === 'next') {
            // Try next editable cell on the same row
            if (idx >= 0 && idx + 1 < $cells.length) {
                return descriptorFromCell($cells.eq(idx + 1));
            }
            // Wrap to first editable cell of next row
            var $tr = $row.next('tr');
            while ($tr.length) {
                var $first = $tr.find('td.gt-editable-cell').first();
                if ($first.length) return descriptorFromCell($first);
                /* c8 ignore next */
                $tr = $tr.next('tr');
            }
            return null;
        }

        if (direction === 'prev') {
            if (idx > 0) {
                return descriptorFromCell($cells.eq(idx - 1));
            }
            // Wrap to last editable cell of previous row
            var $prev = $row.prev('tr');
            while ($prev.length) {
                var $last = $prev.find('td.gt-editable-cell').last();
                if ($last.length) return descriptorFromCell($last);
                /* c8 ignore next */
                $prev = $prev.prev('tr');
            }
            return null;
        }

        return null;
    };

    /**
     * Wait briefly for the in-flight save to settle, then enter edit mode on
     * the cell identified by { entryId, fieldId }.
     */
    GravityTable.prototype.scheduleEditOnTarget = function (target) {
        var self = this;
        if (!target) return;
        setTimeout(function () {
            var $wrapper = $('#' + self.wrapperId);
            var $cell = $wrapper.find(
                'td.gt-editable-cell[data-entry-id="' + target.entryId + '"][data-field-id="' + target.fieldId + '"]'
            ).first();
            if ($cell.length) {
                self.editField($cell);
            }
        }, 250);
    };

})(window);

/* ===== assets/js/frontend/edit-ajs-lookup.js ===== */
/**
 * TableCrafter — frontend/edit-ajs-lookup.js
 *
 * Search-as-you-type inline editor for AJS Toolkit client / pit /
 * destination / material lookups. Fourth slice under #833.
 *
 * Public surface (attached directly to GravityTable.prototype):
 *
 *   - editAjsToolkitLookupField($field, fieldId, currentValue, entryId)
 *
 * The editor:
 *   1. Inspects this.config.lookup_fields[fieldId].table to pick the
 *      lookup type ('clients' / 'pits' / 'destinations' / 'materials').
 *   2. Renders a text input + hidden suggestion <ul> inside $field.
 *   3. Debounces typing at 200ms, calls the toolkit's
 *      ajs_toolkit_search AJAX endpoint with q + type + nonce.
 *   4. On suggestion click: writes the text and calls this.saveField.
 *   5. On Enter: saves the current input value via this.saveField.
 *   6. On Escape: restores the original value, restores original-padding.
 *   7. On blur (200ms grace): saves if changed, otherwise restores.
 *   8. Focus + select-all on entry (50ms setTimeout to ensure the DOM
 *      has settled before setSelectionRange).
 *
 * Pairs with edit-save.js (slice 2) — every save path routes through
 * this.saveField on the prototype.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    /**
     * Search-as-you-type inline edit for AJS Toolkit client lookup (matches frontend form behavior).
     */
    GravityTable.prototype.editAjsToolkitLookupField = function ($field, fieldId, currentValue, entryId) {
        var self = this;
        var tk = this.config.ajs_toolkit || {};
        var lf = this.config.lookup_fields && this.config.lookup_fields[fieldId];
        var lookupType = 'clients';
        if (lf && lf.table && String(lf.table).indexOf('ajs_source_pits') !== -1) {
            lookupType = 'pits';
        } else if (lf && lf.table && String(lf.table).indexOf('ajs_load_destinations') !== -1) {
            lookupType = 'destinations';
        } else if (lf && lf.table && String(lf.table).indexOf('ajs_materials') !== -1) {
            lookupType = 'materials';
        }

        var wrap = '<div class="gt-ajs-lookup-wrap" style="position:relative;width:100%;min-width:160px;">';
        wrap += '<input type="text" class="gt-edit-input gt-ajs-lookup-input" autocomplete="off" inputmode="search" value="' + self.escapeHtml(currentValue) + '" />';
        wrap += '<ul class="gt-ajs-lookup-suggest" style="display:none;position:absolute;left:0;right:0;top:100%;z-index:10060;max-height:220px;overflow:auto;background:#fff;border:1px solid #0073aa;list-style:none;margin:0;padding:0;"></ul>';
        wrap += '</div>';

        $field.html(wrap);
        $field.addClass('gt-editing');

        var $input = $field.find('.gt-ajs-lookup-input');
        var $ul = $field.find('.gt-ajs-lookup-suggest');
        var debounceT;

        function runSearch(term) {
            $.get(tk.ajax_url, {
                action: 'ajs_toolkit_search',
                q: term,
                type: lookupType,
                nonce: tk.nonce
            }).done(function (data) {
                $ul.empty();
                var results = (data && data.results) ? data.results : [];
                if (!results.length) {
                    $ul.hide();
                    return;
                }
                results.forEach(function (row) {
                    var text = row.text || row.id || '';
                    var $li = $('<li/>').css({ padding: '8px 10px', cursor: 'pointer' }).text(text);
                    $li.on('mousedown', function (e) {
                        /* c8 ignore next */
                        e.preventDefault();
                    });
                    $li.on('click', function () {
                        $input.val(text);
                        $ul.hide().empty();
                        self.saveField(entryId, fieldId, text, $field);
                    });
                    $ul.append($li);
                });
                $ul.show();
            });
        }

        $input.on('input', function () {
            clearTimeout(debounceT);
            var v = String($(this).val() || '').trim();
            debounceT = setTimeout(function () {
                if (v.length < 1) {
                    $ul.hide().empty();
                    return;
                }
                runSearch(v);
            }, 200);
        });

        $input.on('keydown', function (e) {
            if (e.key === 'Escape') {
                e.preventDefault();
                $field.html(self.escapeHtml(currentValue));
                if ($field.hasClass('gt-editable-cell') && $field.data('original-padding')) {
                    $field.css('padding', $field.data('original-padding'));
                    $field.removeData('original-padding');
                }
                $field.removeClass('gt-editing');
            } else if (e.key === 'Enter') {
                e.preventDefault();
                self.saveField(entryId, fieldId, $input.val(), $field);
            }
        });

        $input.on('blur', function () {
            setTimeout(function () {
                if (!$field.find('.gt-ajs-lookup-input').length) {
                    /* c8 ignore next */
                    return;
                }
                $ul.hide();
                var nv = $input.val();
                var ov = $field.data('original-value') || '';
                if (nv !== ov) {
                    self.saveField(entryId, fieldId, nv, $field);
                } else {
                    $field.html(self.escapeHtml(ov));
                    if ($field.hasClass('gt-editable-cell') && $field.data('original-padding')) {
                        $field.css('padding', $field.data('original-padding'));
                        $field.removeData('original-padding');
                    }
                    $field.removeClass('gt-editing');
                }
            }, 200);
        });

        setTimeout(function () {
            $input.focus();
            if ($input[0] && $input[0].setSelectionRange) {
                $input[0].setSelectionRange(0, $input.val().length);
            }
        }, 50);
    };

})(window);

/* ===== assets/js/frontend/presets.js ===== */
/**
 * TableCrafter — frontend/presets.js
 *
 * Saved-filter / saved-view "preset" subsystem. Fifth slice under
 * #833. Eight helpers, ~232 lines.
 *
 * Public surface (attached directly to GravityTable.prototype):
 *
 *   - initPresets()              — bootstrap UI, wire toolbar
 *                                  handlers (select change, save,
 *                                  delete), kick off loadPresets.
 *   - findPresetById(presetId)   — pure linear search in
 *                                  this._presets.
 *   - renderPresetOptions()      — populate the toolbar <select>
 *                                  from this._presets, restore the
 *                                  currently-selected value if it
 *                                  survived the refresh.
 *   - loadPresets()              — AJAX gt_get_filter_presets, write
 *                                  response into this._presets, then
 *                                  renderPresetOptions.
 *   - savePresetPrompt()         — window.prompt for the name, then
 *                                  AJAX gt_save_filter_preset with the
 *                                  current this.filters payload.
 *   - deletePreset(presetId)     — AJAX gt_delete_filter_preset, then
 *                                  refresh options.
 *   - applyPresetById(presetId)  — findPresetById + applyPresetFilters.
 *   - applyPresetFilters(filters)— write filter values into the input
 *                                  DOM, handling each filter type
 *                                  (date_range, number_range, dropdown,
 *                                  lookup, checkboxes, text), then call
 *                                  this.applyFilters.
 *
 * Backend AJAX actions used:
 *   - gt_get_filter_presets
 *   - gt_save_filter_preset
 *   - gt_delete_filter_preset
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    GravityTable.prototype.initPresets = function () {
        var self = this;
        var $wrapper = $('#' + this.wrapperId);
        var $bar = $wrapper.find('.gt-presets');
        if (!$bar.length) return;

        this._presets = [];

        var tableId = parseInt($bar.attr('data-table-id'), 10) || 0;
        if (!tableId) return;
        this._presetTableId = tableId;

        $bar.on('change', '.gt-preset-select', function () {
            var presetId = $(this).val();
            if (!presetId) {
                $bar.find('.gt-preset-delete').hide();
                return;
            }
            $bar.find('.gt-preset-delete').show();
            self.applyPresetById(presetId);
        });

        $bar.on('click', '.gt-preset-save', function () {
            self.savePresetPrompt();
        });

        $bar.on('click', '.gt-preset-delete', function () {
            var $sel = $bar.find('.gt-preset-select');
            var presetId = $sel.val();
            if (!presetId) return;
            var preset = self.findPresetById(presetId);
            var name = preset ? preset.name : 'this preset';
            if (!confirm('Delete preset "' + name + '"?')) return;
            self.deletePreset(presetId);
        });

        this.loadPresets();
    };

    GravityTable.prototype.findPresetById = function (presetId) {
        var arr = this._presets || [];
        for (var i = 0; i < arr.length; i++) {
            if (arr[i].id === presetId) return arr[i];
        }
        return null;
    };

    // #1605 — capture the full visitor-adjustable view state so a
    // preset survives device changes (column order, sort stack, page
    // size — the pieces that previously lived only in localStorage).
    GravityTable.prototype.capturePresetView = function () {
        var $wrapper = $('#' + this.wrapperId);
        var order = [];
        $wrapper.find('thead tr').first().find('th[data-field-id]').each(function () {
            order.push(String($(this).attr('data-field-id')));
        });
        var stack = Array.isArray(this.sortStack) && this.sortStack.length
            ? this.sortStack
            : (this.sortField ? [{ field: String(this.sortField), order: this.sortOrder || 'desc' }] : []);
        return {
            column_order: order,
            sort_stack: stack,
            per_page: parseInt((this.config && this.config.per_page) || 0, 10) || 25
        };
    };

    // #1605 — re-apply a stored view. Column order routes through the
    // existing stored-order helpers so localStorage stays in sync and
    // the established head/body reorder path does the DOM work.
    GravityTable.prototype.applyPresetView = function (view) {
        if (!view || typeof view !== 'object') return;
        if (Array.isArray(view.column_order) && view.column_order.length
            && typeof this.saveStoredColumnOrder === 'function') {
            this.saveStoredColumnOrder(view.column_order);
            if (typeof this.applyStoredColumnOrderToHead === 'function') {
                this.applyStoredColumnOrderToHead();
            }
            if (typeof this.applyStoredColumnOrderToBody === 'function') {
                this.applyStoredColumnOrderToBody();
            }
        }
        if (Array.isArray(view.sort_stack) && view.sort_stack.length) {
            this.sortStack = view.sort_stack;
            this.sortField = view.sort_stack[0].field;
            this.sortOrder = view.sort_stack[0].order;
        }
        var pp = parseInt(view.per_page, 10);
        if (this.config && !isNaN(pp) && pp > 0) {
            this.config.per_page = pp;
        }
    };

    GravityTable.prototype.findDefaultPreset = function () {
        var arr = this._presets || [];
        for (var i = 0; i < arr.length; i++) {
            if (arr[i] && arr[i].is_default) return arr[i];
        }
        return null;
    };

    // #1605 — one-shot default-view application on load. Skips when
    // the visitor already has explicit state (URL filters, restored
    // localStorage filters, or an active search) so the pin only
    // fills the fresh-device gap it was designed for.
    GravityTable.prototype.maybeApplyDefaultPreset = function () {
        if (this._defaultViewApplied) return;
        this._defaultViewApplied = true;
        var hasFilters = this.filters && typeof this.filters === 'object'
            && Object.keys(this.filters).length > 0;
        var hasSearch = typeof this.searchTerm === 'string' && this.searchTerm !== '';
        if (hasFilters || hasSearch) return;
        var def = this.findDefaultPreset();
        if (!def) return;
        this.applyPresetById(def.id);
    };

    GravityTable.prototype.renderPresetOptions = function () {
        var $wrapper = $('#' + this.wrapperId);
        var $sel = $wrapper.find('.gt-preset-select');
        if (!$sel.length) return;
        var current = $sel.val();
        var html = '<option value="">Saved presets&hellip;</option>';
        var arr = this._presets || [];
        for (var i = 0; i < arr.length; i++) {
            // #1605 — star-prefix the default view.
            var label = (arr[i].is_default ? '★ ' : '') + arr[i].name;
            html += '<option value="' + this.escapeHtml(arr[i].id) + '">' + this.escapeHtml(label) + '</option>';
        }
        $sel.html(html);
        if (current && this.findPresetById(current)) {
            $sel.val(current);
            $wrapper.find('.gt-preset-delete').show();
        } else {
            $wrapper.find('.gt-preset-delete').hide();
        }
    };

    GravityTable.prototype.loadPresets = function () {
        var self = this;
        if (!this._presetTableId) return;
        $.post(this.config.ajax_url, {
            action: 'gt_get_filter_presets',
            nonce: this.config.nonce,
            table_id: this._presetTableId
        }, function (response) {
            if (response && response.success && response.data && Array.isArray(response.data.presets)) {
                self._presets = response.data.presets;
                self.renderPresetOptions();
                // #1605 — default pin auto-applies on fresh devices.
                if (typeof self.maybeApplyDefaultPreset === 'function') {
                    self.maybeApplyDefaultPreset();
                }
            }
        });
    };

    GravityTable.prototype.savePresetPrompt = function () {
        var self = this;
        var name = window.prompt('Save current view as preset (filters, column order, sort, page size). Enter a name:');
        if (name === null) return;
        name = (name || '').trim();
        if (!name) {
            alert('Preset name is required.');
            return;
        }
        var filtersJson = JSON.stringify(this.filters || {});
        // #1605 — capture the full view state + default pin.
        var viewJson = JSON.stringify(typeof this.capturePresetView === 'function' ? this.capturePresetView() : {});
        var isDefault = window.confirm('Make this your default view for this table?') ? '1' : '0';
        $.post(this.config.ajax_url, {
            action: 'gt_save_filter_preset',
            nonce: this.config.nonce,
            table_id: this._presetTableId,
            name: name,
            filters: filtersJson,
            view: viewJson,
            is_default: isDefault
        }, function (response) {
            if (response && response.success && response.data) {
                self._presets = response.data.presets || [];
                self.renderPresetOptions();
                $('#' + self.wrapperId).find('.gt-preset-select').val(response.data.preset_id);
                $('#' + self.wrapperId).find('.gt-preset-delete').show();
            } else {
                alert('Failed to save preset: ' + ((response && response.data) || 'Unknown error'));
            }
        }).fail(function () {
            alert('Failed to save preset.');
        });
    };

    GravityTable.prototype.deletePreset = function (presetId) {
        var self = this;
        $.post(this.config.ajax_url, {
            action: 'gt_delete_filter_preset',
            nonce: this.config.nonce,
            table_id: this._presetTableId,
            preset_id: presetId
        }, function (response) {
            if (response && response.success && response.data) {
                self._presets = response.data.presets || [];
                self.renderPresetOptions();
                $('#' + self.wrapperId).find('.gt-preset-select').val('');
                $('#' + self.wrapperId).find('.gt-preset-delete').hide();
            } else {
                alert('Failed to delete preset.');
            }
        });
    };

    GravityTable.prototype.applyPresetById = function (presetId) {
        var preset = this.findPresetById(presetId);
        if (!preset) return;
        // #1605 — view first (column order / sort / page size), then
        // filters; applyPresetFilters ends in applyFilters, which
        // reloads entries once with the full state in place.
        if (typeof this.applyPresetView === 'function' && preset.view) {
            this.applyPresetView(preset.view);
        }
        this.applyPresetFilters(preset.filters || {});
    };

    GravityTable.prototype.applyPresetFilters = function (filters) {
        var $wrapper = $('#' + this.wrapperId);

        // Clear current filter inputs first
        $wrapper.find('.gt-filter-input').val('');
        $wrapper.find('.gt-date-display').val('');
        $wrapper.find('.gt-date-html5').val('');
        $wrapper.find('.gt-range-min, .gt-range-max').val('');
        $wrapper.find('.gt-checkbox-filter').prop('checked', false);
        $wrapper.find('.gt-dropdown-filter, .gt-lookup-filter').each(function () {
            if ($(this).prop('multiple')) {
                $(this).val([]);
            } else {
                $(this).val('');
            }
        });

        // Walk filters and populate inputs
        Object.keys(filters || {}).forEach(function (key) {
            var match = /^filter_(.+)$/.exec(key);
            if (!match) return;
            var fieldId = match[1];
            var f = filters[key];
            var $field = $wrapper.find('.gt-filter-field[data-field-id="' + fieldId + '"]');
            if (!$field.length || !f || !f.type) return;

            switch (f.type) {
                case 'date_range':
                    var $singleHtml5 = $field.find('.gt-date-html5:not(.gt-date-from-html5):not(.gt-date-to-html5)');
                    var $fromHtml5 = $field.find('.gt-date-from-html5');
                    var $toHtml5 = $field.find('.gt-date-to-html5');
                    // Saved values are display-formatted; setting the html5 input expects YYYY-MM-DD,
                    // so write into the display input which existing apply path can re-read. The
                    // existing applyFilters reads the html5 input only — so we restore both.
                    if ($singleHtml5.length && f.from && f.from === f.to) {
                        var iso = parseDateToIso(f.from);
                        if (iso) $singleHtml5.val(iso);
                    } else {
                        if (f.from && $fromHtml5.length) {
                            var isoFrom = parseDateToIso(f.from);
                            if (isoFrom) $fromHtml5.val(isoFrom);
                        }
                        if (f.to && $toHtml5.length) {
                            var isoTo = parseDateToIso(f.to);
                            if (isoTo) $toHtml5.val(isoTo);
                        }
                    }
                    break;
                case 'number_range':
                    if (f.min != null) $field.find('.gt-range-min').val(f.min);
                    if (f.max != null) $field.find('.gt-range-max').val(f.max);
                    break;
                case 'dropdown':
                case 'lookup':
                    var $sel = $field.find('.gt-dropdown-filter, .gt-lookup-filter');
                    if (f.values && Array.isArray(f.values)) {
                        $sel.val(f.values);
                    } else if (f.value != null) {
                        $sel.val(f.value);
                    }
                    $sel.trigger('change');
                    break;
                case 'checkboxes':
                    if (Array.isArray(f.values)) {
                        f.values.forEach(function (v) {
                            $field.find('.gt-checkbox-filter[value="' + v + '"]').prop('checked', true);
                        });
                    }
                    break;
                case 'text':
                default:
                    if (f.value != null) {
                        $field.find('.gt-filter-input').val(f.value);
                    }
                    break;
            }
        });

        function parseDateToIso(val) {
            // Parses common formats (m/d/Y, Y-m-d) into yyyy-mm-dd for HTML5 date inputs
            if (!val) return '';
            if (/^\d{4}-\d{2}-\d{2}$/.test(val)) return val;
            var m = /^(\d{1,2})[\/-](\d{1,2})[\/-](\d{4})$/.exec(val);
            if (m) {
                var mm = ('0' + m[1]).slice(-2);
                var dd = ('0' + m[2]).slice(-2);
                return m[3] + '-' + mm + '-' + dd;
            }
            return '';
        }

        this.applyFilters();
    };

})(window);

/* ===== assets/js/frontend/print.js ===== */
/**
 * TableCrafter — frontend/print.js
 *
 * Print-preparation helpers. Sixth slice under #833. Three methods:
 *
 *   - initPrint()              — wire the .gt-print-btn click to a
 *                                preparePrintHeader + (optional)
 *                                load-all-rows + window.print sequence.
 *                                Honors config.print_all_rows (default
 *                                true) and the #531 5000-row cap with
 *                                an afterprint listener that restores
 *                                pagination.
 *   - preparePrintHeader()     — fill .gt-print-header with timestamp
 *                                and active-filter summary.
 *   - summarizeActiveFilters() — pure helper rendering the active
 *                                filter state as a human-readable
 *                                string (handles each filter type).
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    GravityTable.prototype.initPrint = function () {
        var self = this;
        var $wrapper = $('#' + this.wrapperId);
        $wrapper.on('click', '.gt-print-btn', function (e) {
            e.preventDefault();

            // #1024 — re-entrancy guard. If the previous print-prep is still
            // in flight (AJAX hasn't returned yet), a second click would
            // mutate per_page from its already-mutated value, corrupt the
            // restore state, and stack a second afterprint listener.
            // The user-visible symptom is the table sitting in a loading
            // state with no end in sight while requests pile up.
            if (self._printPrepInFlight) {
                return;
            }

            self.preparePrintHeader();

            // #531 slice 1: print-all-rows. When the per-table setting is on
            // (default) we fetch every entry into the DOM before calling
            // window.print() so the printout includes the full list rather
            // than just the currently visible page. The original pagination
            // state is restored on the browser's afterprint event.
            var printAll = (self.config && typeof self.config.print_all_rows !== 'undefined')
                ? !!self.config.print_all_rows
                : true;

            if (!printAll) {
                window.print();
                return;
            }

            // Hide the legacy "current page only" notice — no longer relevant
            // when we're loading everything.
            $wrapper.find('.gt-print-pagination-notice').hide();

            // #1024 — skip the heavy refetch when the table already has every
            // entry in the DOM. The original print-all-rows flow ALWAYS
            // pushed per_page to 5000 and re-fetched even when the current
            // pagination already covered every row, which on slow networks
            // looked like indefinite loading to the user. If we already
            // know totalEntries and the DOM has at least that many tbody
            // rows, just call window.print() directly.
            var visibleRowCount = $wrapper.find('tbody tr').length;
            if (typeof self.totalEntries === 'number'
                && self.totalEntries > 0
                && visibleRowCount >= self.totalEntries) {
                setTimeout(function () { window.print(); }, 50);
                return;
            }

            var originalPerPage = self.config.per_page;
            var originalPage = self.currentPage;

            // Bind a one-shot afterprint listener so the on-screen view goes
            // back to the paginated state once the user dismisses the print
            // dialog. addEventListener used so we don't clobber any other
            // afterprint handlers the host page may have set.
            var restored = false;
            var onAfterPrint = function () {
                if (restored) { return; }
                restored = true;
                self.config.per_page = originalPerPage;
                self.currentPage = originalPage;
                self.loadEntries();
                window.removeEventListener('afterprint', onAfterPrint);
                $wrapper.find('.gt-print-pagination-notice').show();
            };
            window.addEventListener('afterprint', onAfterPrint);

            // Cap at 5000 so a runaway dataset doesn't lock the browser. For
            // the typical roster / schedule / price-list use case this is
            // far above the realistic upper bound.
            //
            // #531 slice 1.1: never SHRINK below the original per_page. If a
            // table already has per_page=10000, the print path keeps that
            // higher value so the printout doesn't lose rows. Print is
            // supposed to ADD rows, not remove them.
            self.config.per_page = Math.max(originalPerPage || 0, 5000);
            self.currentPage = 1;
            self._printPrepInFlight = true; // #1024 — re-entrancy guard
            self.loadEntries(function (response) {
                self._printPrepInFlight = false; // clear in-flight flag
                // #531 slice 1.2: only print when the AJAX succeeded. On
                // failure the tbody now holds an error message; printing
                // that would just put garbage on the page.
                if (response && response.success) {
                    // Defer the print dialog until the next tick so the
                    // DOM has a chance to commit the newly rendered rows.
                    setTimeout(function () { window.print(); }, 50);
                } else {
                    // Restore pagination immediately — the afterprint event
                    // won't fire because the dialog never opened. Without
                    // this, the table stays at per_page=5000 with broken
                    // tbody content.
                    onAfterPrint();
                }
            });
        });
    };

    GravityTable.prototype.preparePrintHeader = function () {
        var $wrapper = $('#' + this.wrapperId);
        var $header = $wrapper.find('.gt-print-header');
        if (!$header.length) return;

        var now = new Date();
        var dateStr = now.toLocaleDateString(undefined, {
            year: 'numeric', month: 'long', day: 'numeric'
        }) + ' · ' + now.toLocaleTimeString(undefined, {
            hour: 'numeric', minute: '2-digit'
        });
        $header.find('.gt-print-header__date').text(dateStr);

        var summary = this.summarizeActiveFilters();
        var $filters = $header.find('.gt-print-header__filters');
        if (summary) {
            $filters.html('<strong>Filters:</strong> ' + this.escapeHtml(summary));
        } else {
            $filters.html('<em>' + this.escapeHtml($filters.attr('data-empty-text') || 'No filters applied') + '</em>');
        }
    };

    GravityTable.prototype.summarizeActiveFilters = function () {
        var $wrapper = $('#' + this.wrapperId);
        var parts = [];

        var search = $wrapper.find('.gt-search-input').val();
        if (search && search.trim() !== '') {
            parts.push('Search = "' + search.trim() + '"');
        }

        var filters = this.filters || {};
        Object.keys(filters).forEach(function (key) {
            var match = /^filter_(.+)$/.exec(key);
            if (!match) return;
            var fieldId = match[1];
            var label = $wrapper.find('.gt-filter-field[data-field-id="' + fieldId + '"] label').first().text().trim() || ('Field ' + fieldId);
            var f = filters[key];
            if (!f) return;
            // #1566: 0 must be treated as a real value, not falsy. min, max,
            // from, to may legitimately be 0 (or the string '0') -- a filter
            // "weight = 0 to 10" must show in the summary including the
            // literal 0 endpoint.
            var has = function (v) { return v !== undefined && v !== null && v !== ''; };
            var disp = function (v) { return has(v) ? v : '...'; };
            switch (f.type) {
                case 'date_range':
                    if (has(f.from) && has(f.to) && f.from === f.to) parts.push(label + ' = ' + f.from);
                    else if (has(f.from) || has(f.to)) parts.push(label + ' ' + disp(f.from) + ' to ' + disp(f.to));
                    break;
                case 'number_range':
                    if (has(f.min) || has(f.max)) parts.push(label + ' ' + disp(f.min) + ' to ' + disp(f.max));
                    break;
                case 'lookup':
                case 'dropdown':
                    if (f.values && f.values.length) parts.push(label + ' = ' + f.values.join(', '));
                    else if (f.value) parts.push(label + ' = ' + f.value);
                    break;
                case 'checkboxes':
                    if (f.values && f.values.length) parts.push(label + ' = ' + f.values.join(', '));
                    break;
                case 'text':
                default:
                    if (f.value) parts.push(label + ' = ' + f.value);
                    break;
            }
        });

        return parts.join(' · ');
    };

})(window);

/* ===== assets/js/frontend/push-to-source.js ===== */
/**
 * TableCrafter — frontend/push-to-source.js
 *
 * #613 phase 2 slice 3 — pushRowToSource prototype method on GravityTable.
 * Calls the gt_push_row AJAX endpoint shipped in v4.197.0.
 *
 * Surface:
 *   - pushRowToSource(rowId, payload, [onResponse]) — posts the row update
 *     to the configured external data source (currently JSON; Airtable +
 *     Notion follow in later slices). Optional callback fires with the
 *     response object (success or error envelope).
 *
 * @since 4.198.0
 */
(function (window) {
    'use strict';

    if (typeof window.GravityTable !== 'function') {
        // Stub constructor so this module can load in any order.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    window.GravityTable.prototype.pushRowToSource = function (rowId, payload, onResponse, opts) {
        var self = this;
        opts = opts || {};
        // #613 phase 2 slice 17 (v4.212.0) — auto-retry with backoff.
        // _retryAttempt is used internally to bound recursion to 1 retry.
        var retryAttempt = typeof opts._retryAttempt === 'number' ? opts._retryAttempt : 0;
        var retryDelayMs = typeof opts._retryDelay === 'number' ? opts._retryDelay : 1100;

        // Defensive guards. The server-side has the same checks (typed
        // WP_Errors); this avoids the round-trip cost for obviously-invalid
        // inputs.
        if (!rowId || typeof rowId !== 'string' || rowId === '') {
            return;
        }
        if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) {
            return;
        }

        var data = {
            action:   'gt_push_row',
            nonce:    self.config && self.config.nonce ? self.config.nonce : '',
            table_id: self.config && self.config.table_id ? self.config.table_id : 0,
            row_id:   rowId,
            payload:  payload,
        };

        // #613 phase 2 slice 16 (v4.211.0) — conflict-detection baseline.
        // Send the last-known baseline so the server can refuse the push
        // if the row has been modified since this client loaded it.
        // Backward compat: callers that don't maintain self._pushBaselines
        // see no baseline_lastmod field, so the server-side check no-ops.
        if (self._pushBaselines && typeof self._pushBaselines[rowId] !== 'undefined') {
            data.baseline_lastmod = self._pushBaselines[rowId];
        }

        $.post(self.config.ajax_url, data, function (response) {
            // #613 — bump the baseline on success so subsequent pushes use
            // a fresh token. The server returns the new baseline in
            // response.data.new_baseline (added in v4.211.0).
            if (response && response.success && self._pushBaselines
                && response.data && typeof response.data.new_baseline !== 'undefined') {
                self._pushBaselines[rowId] = response.data.new_baseline;
            }

            // #613 phase 2 slice 17 (v4.212.0) — auto-retry once on
            // rate_limited. The server-side rate window is 1s (per
            // TC_Push_Rate_Limiter::WINDOW_SECONDS); waiting just past
            // 1s should clear it. Bound to one retry so a perpetually
            // throttled state surfaces the error instead of looping.
            if (response && response.success === false
                && response.data && response.data.code === 'rate_limited'
                && retryAttempt === 0) {
                var retry = function () {
                    // Use the prototype's method, not self's, so test stubs
                    // (plain ctx objects without the prototype chain) work too.
                    window.GravityTable.prototype.pushRowToSource.call(self, rowId, payload, onResponse, { _retryAttempt: 1, _retryDelay: retryDelayMs });
                };
                if (retryDelayMs > 0) {
                    setTimeout(retry, retryDelayMs);
                } else {
                    retry();
                }
                return;
            }

            if (typeof onResponse === 'function') {
                try {
                    onResponse(response);
                } catch (e) {
                    // Swallow user-callback errors so a bad onResponse can't
                    // poison the push flow.
                    if (window.console && window.console.error) {
                        window.console.error('GT pushRowToSource: callback threw', e);
                    }
                }
            }
        });
    };

})(window);

/* ===== assets/js/frontend/row-actions.js ===== */
/**
 * TableCrafter — frontend/row-actions.js
 *
 * Handlers behind the per-row action buttons. Seventh slice under
 * #833. Pairs with actions-cell.js (#832 slice 7) which renders the
 * buttons themselves.
 *
 * Public surface (attached directly to GravityTable.prototype):
 *
 *   - loadWooCommerceProducts() — populate the WC product list via
 *     gt_get_wc_products AJAX. Used when the table is configured as
 *     a WC product browser (config.is_wc_products_table).
 *   - createWooCommerceProduct(entryId, $btn) — POST to
 *     gt_create_wc_product to spin up a draft WC product from a row.
 *     Confirms first, swaps button to a spinner icon, opens the
 *     edit URL in a new tab on success, alerts on failure.
 *   - viewEntryHistory(entryId) — AJAX gt_get_entry_history and
 *     render the change-log in the detail popup (showDetailsPopup
 *     lives in detail-popup.js, #837).
 *   - triggerInlineEditForEntry(entryId) — find a row by entryId in
 *     the DOM, scroll to it, mark it gt-row-selected, and call
 *     showEditIndicator. Used by deep-link URLs and the inline-edit
 *     icon.
 *
 * Callees that remain in frontend.js or other modules:
 *   - this.escapeHtml (frontend.js)
 *   - this.showDetailsPopup (detail-popup.js, #837)
 *   - this.showEditIndicator (frontend.js)
 *   - this.showUndoToast (edit-history.js, #833 slice 1; defensive
 *     `typeof` check preserved)
 *   - this.renderSSPEntries (ssp.js, #832 slice 2)
 *   - this.updatePagination (pagination.js)
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    GravityTable.prototype.loadWooCommerceProducts = function () {
        var self = this;
        var $wrapper = $('#' + this.wrapperId);
        var $tbody = $wrapper.find('tbody');

        $wrapper.addClass('gt-table-loading');
        $tbody.html('<tr class="gt-loading-row"><td colspan="' + ($wrapper.find('thead th').length) + '"><div class="gt-loading">Loading products...</div></td></tr>');

        var data = {
            action:     'gt_get_wc_products',
            nonce:      this.config.nonce,
            table_id:   this.config.table_id,
            page:       this.currentPage,
            per_page:   this.config.per_page,
            search:     this.searchTerm || '',
            orderby:    this.sortField   || 'date',
            order:      (this.sortOrder  || 'desc').toUpperCase()
        };

        $.extend(data, this.filters);

        $.post(this.config.ajax_url, data, function (response) {
            $wrapper.removeClass('gt-table-loading');
            if (response.success && response.data) {
                self.totalEntries = parseInt(response.data.total, 10) || 0;
                self.renderSSPEntries(
                    (response.data.entries || []).map(function (p) {
                        return [p.thumbnail, p.name, p.sku, p.price, p.stock_status, p.rating, p.add_to_cart];
                    })
                );
                self.updatePagination(self.totalEntries);
            } else {
                /* c8 ignore next */
                $tbody.html('<tr><td colspan="' + ($wrapper.find('thead th').length) + '"><div class="gt-error">Error loading products</div></td></tr>');
            }
        }).fail(function () {
            /* c8 ignore next */
            $wrapper.removeClass('gt-table-loading');
            /* c8 ignore next */
            $tbody.html('<tr><td colspan="' + ($wrapper.find('thead th').length) + '"><div class="gt-error">Error loading products</div></td></tr>');
        });
    };

    GravityTable.prototype.createWooCommerceProduct = function (entryId, $btn) {
        var self = this;
        if (!entryId) return;
        if (!self.config.woocommerce || !self.config.woocommerce.active) {
            alert('WooCommerce is not active on this site.');
            return;
        }
        if (!confirm('Create a draft WooCommerce product from this entry?')) return;

        var origIcon = $btn.text();
        $btn.text('⏳').prop('disabled', true);

        $.post(this.config.ajax_url, {
            action: 'gt_create_wc_product',
            nonce: this.config.nonce,
            entry_id: entryId,
            table_id: this.config.table_id || 0
        }, function (response) {
            $btn.text(origIcon).prop('disabled', false);
            if (response && response.success) {
                var d = response.data || {};
                var msg = 'Created product #' + d.product_id + ' (' + (d.product_title || 'untitled') + ')';
                if (typeof self.showUndoToast === 'function') {
                    self.showUndoToast(msg);
                } else {
                    alert(msg);
                }
                if (d.edit_url) {
                    window.open(d.edit_url, '_blank', 'noopener');
                }
            } else {
                var err = (response && response.data && response.data.message) || (response && response.data) || 'Unknown error';
                alert('Could not create product: ' + err);
            }
        }).fail(function () {
            $btn.text(origIcon).prop('disabled', false);
            alert('Request failed. Please try again.');
        });
    };

    GravityTable.prototype.viewEntryHistory = function (entryId) {
        var self = this;
        this.showDetailsPopup('<div class="gt-history-loading">Loading edit history…</div>');

        $.post(this.config.ajax_url, {
            action: 'gt_get_entry_history',
            nonce: this.config.nonce,
            entry_id: entryId
        }, function (response) {
            if (!response.success) {
                self.showDetailsPopup('<div class="gt-history-error">Error: ' + self.escapeHtml(String(response.data || 'Unknown error')) + '</div>');
                return;
            }
            var data = response.data;
            var html = '<div class="gt-history-container">';
            html += '<h3>Edit History &mdash; Entry #' + self.escapeHtml(String(data.entry_id)) + '</h3>';
            if (!data.records || data.records.length === 0) {
                html += '<p class="gt-history-empty">No edits recorded for this entry yet.</p>';
            } else {
                html += '<ol class="gt-history-list">';
                data.records.forEach(function (rec) {
                    html += '<li class="gt-history-item">';
                    html += '<div class="gt-history-meta">';
                    html += '<span class="gt-history-when">' + self.escapeHtml(rec.created_at_display) + '</span>';
                    html += ' · <span class="gt-history-who">' + self.escapeHtml(rec.user_name) + '</span>';
                    html += '</div>';
                    html += '<div class="gt-history-field"><strong>' + self.escapeHtml(rec.field_label) + '</strong></div>';
                    html += '<div class="gt-history-diff">';
                    html += '<span class="gt-history-old">' + (rec.old_value === null || rec.old_value === '' ? '<em>(empty)</em>' : self.escapeHtml(String(rec.old_value))) + '</span>';
                    html += ' <span class="gt-history-arrow">&rarr;</span> ';
                    html += '<span class="gt-history-new">' + (rec.new_value === null || rec.new_value === '' ? '<em>(empty)</em>' : self.escapeHtml(String(rec.new_value))) + '</span>';
                    html += '</div>';
                    html += '</li>';
                });
                html += '</ol>';
            }
            html += '<div class="gt-details-footer"><button type="button" class="gt-close-popup">Close</button></div>';
            html += '</div>';
            self.showDetailsPopup(html);
        }).fail(function () {
            self.showDetailsPopup('<div class="gt-history-error">Failed to load history.</div>');
        });
    };

    GravityTable.prototype.triggerInlineEditForEntry = function (entryId) {
        var self = this;
        var $wrapper = $('#' + this.wrapperId);
        var $row = $wrapper.find('tbody tr').filter(function () {
            return $(this).find('[data-entry-id="' + entryId + '"]').length > 0;
        }).first();
        if (!$row.length) return;
        $wrapper.find('.gt-edit-indicator').remove();
        $wrapper.find('tbody tr').removeClass('gt-row-selected');
        $row.addClass('gt-row-selected');
        if (typeof self.showEditIndicator === 'function') {
            self.showEditIndicator($row, entryId);
        }
        var rowTop = $row.offset() ? $row.offset().top : 0;
        $('html, body').animate({ scrollTop: Math.max(0, rowTop - 100) }, 200);
    };

})(window);

/* ===== assets/js/frontend/lookup-dropdown.js ===== */
/**
 * TableCrafter — frontend/lookup-dropdown.js
 *
 * Generic (non-AJS-toolkit) lookup-field dropdown populator. Eighth
 * slice under #833. One method, ~275 lines — the largest single
 * extraction yet.
 *
 * Public surface (attached directly to GravityTable.prototype):
 *
 *   - populateLookupDropdown($field, fieldId, currentValue)
 *
 * The populator:
 *   1. Validates this.config.lookup_fields[fieldId] is present and
 *      has either source_form (form-based) or type (user/post lookup).
 *      Falls back to a plain text editor on validation failure.
 *   2. Shows "Loading options..." placeholder.
 *   3. Arms a 10-second timeout that falls back to a plain text
 *      editor if AJAX hangs.
 *   4. POSTs to gt_get_lookup_options with the lookup_config payload.
 *   5. On success: renders a <select class="gt-lookup-select"> with
 *      reverse-lookup of current display value → stored option id,
 *      then wires change/keydown/click handlers to save through
 *      this.saveField.
 *   6. On AJAX failure or success=false: shows a brief error
 *      message, then falls back to a plain text editor after 3
 *      seconds with the same keyboard / blur save lifecycle.
 *
 * Pairs with edit-save.js (slice 2) — every save path routes through
 * this.saveField on the prototype.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    GravityTable.prototype.populateLookupDropdown = function ($field, fieldId, currentValue) {
        //console.log('GT Lookup: populateLookupDropdown called for field', fieldId, 'with current value:', currentValue);
        var self = this;
        var lookupConfig = this.config.lookup_fields && this.config.lookup_fields[fieldId];

        //console.log('GT Lookup: this.config.lookup_fields:', this.config.lookup_fields);
        //console.log('GT Lookup: Raw lookupConfig for field ' + fieldId + ':', lookupConfig);

        // Check if lookup configuration exists and is valid (either has source_form for gravity form lookups OR type for user/post lookups)
        if (!lookupConfig || typeof lookupConfig !== 'object' || (!lookupConfig.source_form && !lookupConfig.type)) {
            console.error('GT Lookup: No valid lookup configuration found for field', fieldId);
            //console.log('GT Lookup: Available lookup fields', this.config.lookup_fields);
            //console.log('GT Lookup: Provided config:', lookupConfig);
            //console.log('GT Lookup: Validation failed - lookupConfig exists?', !!lookupConfig);
            //console.log('GT Lookup: Validation failed - is object?', typeof lookupConfig === 'object');
            //console.log('GT Lookup: Validation failed - has source_form?', !!(lookupConfig && lookupConfig.source_form));
            //console.log('GT Lookup: Validation failed - has type?', !!(lookupConfig && lookupConfig.type));
            $field.html('<input type="text" class="gt-edit-input" value="' + self.escapeHtml(currentValue) + '">');

            // Focus and add keyboard handling for fallback text input
            setTimeout(function () {
                var $input = $field.find('.gt-edit-input');
                $input.focus();

                // Add Enter/Esc handling
                $input.on('keydown', function (e) {
                    if (e.key === 'Enter') { // Enter
                        e.preventDefault();
                        var newValue = $input.val();
                        self.saveField($field.data('entry-id'), fieldId, newValue, $field);
                    } else if (e.key === 'Escape') { // Escape
                        e.preventDefault();
                        $field.html(self.escapeHtml(currentValue));
                    }
                });

                // Auto-save on blur
                $input.on('blur', function () {
                    setTimeout(function () {
                        if ($field.find('.gt-edit-input').length > 0) {
                            var newValue = $input.val();
                            self.saveField($field.data('entry-id'), fieldId, newValue, $field);
                        }
                    }, 100);
                });
            }, 10);

            return;
        }

        // Show loading state
        //console.log('GT Lookup: Setting loading state for field', fieldId);
        $field.html('Loading options...');

        // Debug logging
        //console.log('GT Lookup: Starting lookup for field', fieldId);
        //console.log('GT Lookup: Config', lookupConfig);
        //console.log('GT Lookup: AJAX URL', this.config.ajax_url);
        //console.log('GT Lookup: Form ID', this.config.form_id);
        //console.log('GT Lookup: Nonce', this.config.nonce);
        //console.log('GT Lookup: Entire config object:', this.config);

        // Add timeout for stuck loading states
        //console.log('GT Lookup: Setting up 10-second timeout for field', fieldId);
        var timeoutId = setTimeout(function () {
            console.warn('GT Lookup: AJAX request timed out after 10 seconds for field', fieldId, ', falling back to text input');
            //console.log('GT Lookup: Timeout triggered - replacing loading with text input');
            $field.html('<input type="text" class="gt-edit-input" value="' + self.escapeHtml(currentValue) + '">');

            // Setup the text input with proper event handling
            var $input = $field.find('.gt-edit-input');
            setTimeout(function () {
                //console.log('GT Lookup: Focusing fallback text input after timeout');
                $input.focus();

                // Add Enter/Esc handling
                $input.on('keydown', function (e) {
                    if (e.key === 'Enter') { // Enter
                        e.preventDefault();
                        var newValue = $input.val();
                        self.saveField($field.data('entry-id'), fieldId, newValue, $field);
                    } else if (e.key === 'Escape') { // Escape
                        e.preventDefault();
                        $field.html(self.escapeHtml(currentValue));
                    }
                });

                // Auto-save on blur
                $input.on('blur', function () {
                    setTimeout(function () {
                        if ($field.find('.gt-edit-input').length > 0) {
                            var newValue = $input.val();
                            self.saveField($field.data('entry-id'), fieldId, newValue, $field);
                        }
                    }, 100);
                });
            }, 10);
        }, 10000); // 10 second timeout

        // Make AJAX call to get lookup options.
        // #914: previously emitted a `console.log('GT Lookup: Making AJAX
        // request...')` on every customer edit — removed to keep the console
        // clean. Re-enable behind a debug flag if diagnostics needed.

        $.post(this.config.ajax_url, {
            action: 'gt_get_lookup_options',
            nonce: this.config.nonce,
            field_id: fieldId,
            lookup_config: lookupConfig,
            form_id: this.config.form_id
        }, function (response) {
            clearTimeout(timeoutId); // Clear timeout since we got a response
            if (typeof GTDebugFrontend !== 'undefined') {
                GTDebugFrontend.log('lookup', 'AJAX response received', {
                    response: response,
                    responseType: typeof response,
                    success: response.success,
                    data: response.data
                });
            }

            if (response.success && response.data) {
                var inputHtml = '<select class="gt-edit-input gt-lookup-select">';
                inputHtml += '<option value="">Select...</option>';

                // Find current value ID (reverse lookup from display value to stored ID)
                var currentValueId = '';
                $.each(response.data, function (index, option) {
                    if (option.label === currentValue) {
                        currentValueId = option.value;
                    }
                });

                // Add all options
                $.each(response.data, function (index, option) {
                    var selected = (option.value == currentValueId) ? ' selected' : '';
                    inputHtml += '<option value="' + self.escapeHtml(option.value) + '"' + selected + '>' + self.escapeHtml(option.label) + '</option>';
                });

                inputHtml += '</select>';

                $field.html(inputHtml);
                $field.addClass('gt-editing');

                var $select = $field.find('.gt-lookup-select');
                var originalValue = $field.data('original-value');
                var entryId = $field.data('entry-id');

                // Focus the select and ensure it's clickable
                setTimeout(function () {
                    $select.focus();

                    // Force click handling for select dropdown
                    $select.on('mousedown', function (e) {
                        //console.log('GT Lookup: Select mousedown event triggered');
                        // Don't prevent default - allow normal dropdown behavior
                    });

                    $select.on('click', function (e) {
                        //console.log('GT Lookup: Select click event triggered');
                        e.stopPropagation(); // Prevent cell click handlers from interfering
                        // Don't prevent default - allow normal dropdown behavior
                    });
                }, 100);

                // Auto-save on change
                $select.on('change', function () {
                    //console.log('GT Lookup: Select value changed');
                    var newValue = $select.val();
                    var newLabel = $select.find('option:selected').text();
                    self.saveField(entryId, fieldId, newValue, $field, newLabel);
                });

                // Enhanced keyboard handling
                $select.on('keydown', function (e) {
                    if (e.key === 'Enter') { // Enter key
                        e.preventDefault();
                        var newValue = $select.val();
                        var newLabel = $select.find('option:selected').text();
                        self.saveField(entryId, fieldId, newValue, $field, newLabel);
                    } else if (e.key === 'Escape') { // Escape key
                        e.preventDefault();
                        $field.html(self.escapeHtml(originalValue));
                    }
                });

            } else {
                console.error('GT Lookup: AJAX failed or no data', response);
                console.error('GT Lookup: Response success status:', response ? response.success : 'No response');
                console.error('GT Lookup: Response data:', response ? response.data : 'No response');

                // Show error message temporarily
                var errorMsg = 'No data received';
                if (response && !response.success) {
                    errorMsg = response.data || 'Unknown server error';
                }
                $field.html('<div style="color: red; font-size: 12px;">Server Error: ' + errorMsg + '<br>Check console for details</div>');

                // Fallback to text input after 3 seconds
                setTimeout(function () {
                    $field.html('<input type="text" class="gt-edit-input" value="' + self.escapeHtml(currentValue) + '">');

                    var $input = $field.find('.gt-edit-input');
                    setTimeout(function () {
                        $input.focus();

                        $input.on('keydown', function (e) {
                            if (e.key === 'Enter') {
                                e.preventDefault();
                                var newValue = $input.val();
                                self.saveField($field.data('entry-id'), fieldId, newValue, $field);
                            } else if (e.key === 'Escape') {
                                e.preventDefault();
                                $field.html(self.escapeHtml(currentValue));
                            }
                        });

                        $input.on('blur', function () {
                            setTimeout(function () {
                                if ($field.find('.gt-edit-input').length > 0) {
                                    var newValue = $input.val();
                                    self.saveField($field.data('entry-id'), fieldId, newValue, $field);
                                }
                            }, 100);
                        });
                    }, 10);
                }, 3000); // 3 second delay before showing fallback
            }
        }).fail(function (xhr, status, error) {
            clearTimeout(timeoutId); // Clear timeout since we got a response (even if failed)
            console.error('GT Lookup: AJAX request failed');
            console.error('GT Lookup: XHR object:', xhr);
            console.error('GT Lookup: Status:', status);
            console.error('GT Lookup: Error:', error);
            console.error('GT Lookup: Response text:', xhr.responseText);
            console.error('GT Lookup: Status code:', xhr.status);

            // Show error message temporarily
            $field.html('<div style="color: red; font-size: 12px;">AJAX Error: ' + status + ' - ' + error + '<br>Check console for details</div>');

            // Fallback to text input after 3 seconds
            setTimeout(function () {
                $field.html('<input type="text" class="gt-edit-input" value="' + self.escapeHtml(currentValue) + '">');
                var $input = $field.find('.gt-edit-input');
                setTimeout(function () {
                    $input.focus();

                    $input.on('keydown', function (e) {
                        if (e.key === 'Enter') {
                            e.preventDefault();
                            var newValue = $input.val();
                            self.saveField($field.data('entry-id'), fieldId, newValue, $field);
                        } else if (e.key === 'Escape') {
                            e.preventDefault();
                            $field.html(self.escapeHtml(currentValue));
                        }
                    });

                    $input.on('blur', function () {
                        setTimeout(function () {
                            if ($field.find('.gt-edit-input').length > 0) {
                                var newValue = $input.val();
                                self.saveField($field.data('entry-id'), fieldId, newValue, $field);
                            }
                        }, 100);
                    });
                }, 10);
            }, 3000); // 3 second delay before showing fallback
        });
    };

})(window);

/* ===== assets/js/frontend/edit-field.js ===== */
/**
 * TableCrafter — frontend/edit-field.js
 *
 * Inline-editor dispatcher. Ninth slice under #833. One method,
 * ~280 lines.
 *
 * Public surface (attached directly to GravityTable.prototype):
 *
 *   - editField($field)
 *
 * Inspects the cell's data-field-type and renders the appropriate
 * inline editor:
 *
 *   - 'autoToolkitLookup' → delegates to editAjsToolkitLookupField
 *     (slice 4, edit-ajs-lookup.js).
 *   - 'lookup' → delegates to populateLookupDropdown (slice 8,
 *     lookup-dropdown.js).
 *   - 'select' / 'radio' / 'checkbox' / 'date' / 'time' / 'datetime' /
 *     'textarea' / default → renders the editor inline with this.saveField
 *     (slice 2, edit-save.js) on Enter / blur / change.
 *
 * Pairs with every edit-* sibling. Routes through this/self so every
 * inter-module call survives extraction.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    GravityTable.prototype.editField = function ($field) {
        var self = this;

        // Check if frontend editing is enabled
        //console.log('GT Frontend: Checking frontend editing config:', this.config.enable_frontend_editing);
        if (!this.config.enable_frontend_editing) {
            //console.log('GT Frontend: Frontend editing is not enabled for this table');
            //console.log('GT Frontend: Full config:', this.config);
            return;
        }

        //console.log('GT Frontend: Frontend editing is ENABLED - proceeding with edit');

        // Handle both cell-based and span-based editing
        var currentValue = $field.text();

        // Handle empty fields that display as &nbsp;
        if (currentValue === '\u00A0' || currentValue.trim() === '') {
            currentValue = '';
        }

        // For cell-based editing, we need to handle the styling differently
        var isCell = $field.hasClass('gt-editable-cell');
        if (isCell) {
            // Store original cell styling
            $field.data('original-padding', $field.css('padding'));
            $field.css('padding', '0'); // Remove padding so input fits perfectly
        }

        var fieldId = $field.data('field-id');
        var entryId = $field.data('entry-id');
        var colCfg = (this.config.column_config && this.config.column_config[fieldId]) || {};
        var fieldType = colCfg.type || 'text';

        // Store original value for restoration
        $field.data('original-value', currentValue);

        // Debug logging for field editing
        //console.log('GT Edit: Editing field', fieldId, 'Current value:', currentValue);
        //console.log('GT Edit: Available lookup fields:', this.config.lookup_fields);
        //console.log('GT Edit: Full config object keys:', Object.keys(this.config));
        //console.log('GT Edit: Field type:', fieldType);

        // Check if this is a USER-CONFIGURED lookup field (not auto-configured like driver_selector)
        var isUserLookupField = this.config.column_config && this.config.column_config[fieldId] && this.config.column_config[fieldId].lookup_enabled === true;
        // Keep backward compatibility for existing lookup fields - check for either source_form OR type (user/post lookup)
        var isLookupField = this.config.lookup_fields && this.config.lookup_fields[fieldId] && typeof this.config.lookup_fields[fieldId] === 'object' && (this.config.lookup_fields[fieldId].source_form || this.config.lookup_fields[fieldId].type);
        //console.log('GT Edit: Is user lookup field?', isUserLookupField);
        //console.log('GT Edit: Is backward compat lookup field?', isLookupField);
        //console.log('GT Edit: Lookup config for field:', this.config.lookup_fields ? this.config.lookup_fields[fieldId] : 'No lookup fields config');

        // Check if this field has predefined choices (dropdown/select fields from form configuration).
        // #2281: exclude multiselect and checkbox_group — those types own their choices rendering
        // in the switch block below and must NOT be caught here by the single-select path.
        var hasChoices = fieldType !== 'multiselect' && fieldType !== 'checkbox_group' && this.config.column_config && this.config.column_config[fieldId] && this.config.column_config[fieldId].choices && Array.isArray(this.config.column_config[fieldId].choices) && this.config.column_config[fieldId].choices.length > 0;
        //console.log('GT Edit: Has predefined choices?', hasChoices);
        //console.log('GT Edit: Choices:', this.config.column_config[fieldId] ? this.config.column_config[fieldId].choices : 'No column config');

        // Create input based on field type and configuration
        var inputHtml = '';
        var isMobile = window.innerWidth <= 768;

        if (hasChoices) {
            // Create dropdown from predefined choices - this takes priority over lookup fields
            //console.log('GT Edit: Creating predefined choices dropdown with', this.config.column_config[fieldId].choices.length, 'options');
            inputHtml = '<select class="gt-edit-input gt-field-select">';
            inputHtml += '<option value="">Select...</option>';

            var choices = this.config.column_config[fieldId].choices;
            choices.forEach(function (choice) {
                var value = choice.value || choice.text || choice;
                var text = choice.text || choice.label || choice;
                var selected = (value === currentValue) ? ' selected' : '';
                //console.log('GT Edit: Adding choice option:', {value: value, text: text, selected: selected});
                inputHtml += '<option value="' + self.escapeHtml(value) + '"' + selected + '>' + self.escapeHtml(text) + '</option>';
            });

            inputHtml += '</select>';
            //console.log('GT Edit: Predefined choices dropdown HTML:', inputHtml);
        } else if (fieldType === 'ajs_lookup' && self.config.ajs_toolkit && self.config.ajs_toolkit.active) {
            self.editAjsToolkitLookupField($field, fieldId, currentValue, entryId);
            return;
        } else if (isUserLookupField) {
            // User-configured lookup fields use the AJAX lookup system
            //console.log('GT Edit: Field is user-configured lookup field, using AJAX system');
            inputHtml = '<select class="gt-edit-input gt-lookup-select">';
            inputHtml += '<option value="">Select...</option>';

            this.populateLookupDropdown($field, fieldId, currentValue);
            return; // Exit early, the AJAX callback will handle the rest
        } else if (isLookupField) {
            // For backward compatibility lookup fields, create a dropdown with available options
            //console.log('GT Edit: Field is backward compatibility lookup field, using AJAX system');
            inputHtml = '<select class="gt-edit-input gt-lookup-select">';
            inputHtml += '<option value="">Select...</option>';

            // We need to get the lookup options - this requires an AJAX call
            // For now, show a loading state and populate via AJAX
            this.populateLookupDropdown($field, fieldId, currentValue);
            return; // Exit early, the AJAX callback will handle the rest
        } else if (fieldType === 'driver_selector' || fieldType === 'ajs_lookup') {
            // Specialized toolkit lookup fields (dropdown fallback if toolkit script inactive)
            inputHtml = '<select class="gt-edit-input gt-lookup-select">';
            inputHtml += '<option value="">Select...</option>';

            this.populateLookupDropdown($field, fieldId, currentValue);
            return; // Exit early, the AJAX callback will handle the rest
        } else {
            switch (fieldType) {
                case 'textarea':
                    inputHtml = '<textarea class="gt-edit-input" rows="' + (isMobile ? '3' : '2') + '">' + this.escapeHtml(currentValue) + '</textarea>';
                    break;
                case 'date':
                    // Use text input with user's preferred date format instead of HTML5 date picker
                    // This ensures consistency with the configured date format
                    var dateFormat = this.config.date_format || 'm/d/Y';
                    var placeholder = dateFormat.toUpperCase().replace(/[A-Z]/g, function (match) {
                        switch (match) {
                            case 'M': return 'MM';
                            case 'D': return 'DD';
                            case 'Y': return 'YYYY';
                            /* c8 ignore next */
                            default: return match;
                        }
                    });

                    inputHtml = '<input type="text" class="gt-edit-input gt-date-input" value="' + this.escapeHtml(currentValue) + '" placeholder="' + placeholder + '" data-date-format="' + this.escapeHtml(dateFormat) + '">';
                    break;
                case 'email':
                    inputHtml = '<input type="email" class="gt-edit-input" value="' + this.escapeHtml(currentValue) + '">';
                    break;
                case 'number':
                    inputHtml = '<input type="number" class="gt-edit-input" value="' + this.escapeHtml(currentValue) + '">';
                    break;
                // ── #2281 new cell-type editors ────────────────────────────────
                case 'url':
                    inputHtml = '<input type="url" class="gt-edit-input gt-url-input" value="' + this.escapeHtml(currentValue) + '" placeholder="https://">';
                    break;
                case 'datetime':
                    var dtDateFmt = colCfg.date_format || this.config.date_format || 'm/d/Y';
                    var dtTimeFmt = colCfg.time_format || 'H:i';
                    var dtPlaceholder = dtDateFmt.toUpperCase().replace(/[A-Z]/g, function (match) {
                        switch (match) {
                            case 'M': return 'MM';
                            case 'D': return 'DD';
                            case 'Y': return 'YYYY';
                            /* c8 ignore next */
                            default: return match;
                        }
                    }) + ' ' + dtTimeFmt.replace(/i/g, 'MM').replace(/H/g, 'HH').replace(/h/g, 'hh').toUpperCase();
                    inputHtml = '<input type="text" class="gt-edit-input gt-datetime-input"'
                        + ' value="' + this.escapeHtml(currentValue) + '"'
                        + ' placeholder="' + this.escapeHtml(dtPlaceholder) + '"'
                        + ' data-date-format="' + this.escapeHtml(dtDateFmt) + '"'
                        + ' data-time-format="' + this.escapeHtml(dtTimeFmt) + '">';
                    break;
                case 'multiselect':
                    (function () {
                        var msChoices = colCfg.choices || [];
                        var msSelected = [];
                        try { msSelected = JSON.parse(currentValue); } catch (e) { msSelected = []; }
                        inputHtml = '<select multiple class="gt-edit-input gt-multiselect-input">';
                        msChoices.forEach(function (choice) {
                            var v = (choice && choice.value !== undefined) ? choice.value : (typeof choice === 'string' ? choice : '');
                            var t = (choice && (choice.text || choice.label)) || v;
                            var sel = msSelected.indexOf(v) !== -1 ? ' selected' : '';
                            inputHtml += '<option value="' + self.escapeHtml(v) + '"' + sel + '>' + self.escapeHtml(t) + '</option>';
                        });
                        inputHtml += '</select>';
                    }());
                    break;
                case 'checkbox_group':
                    (function () {
                        var cbChoices = colCfg.choices || [];
                        var cbSelected = [];
                        try { cbSelected = JSON.parse(currentValue); } catch (e) { cbSelected = []; }
                        inputHtml = '<div class="gt-edit-input gt-checkbox-group-input">';
                        cbChoices.forEach(function (choice) {
                            var v = (choice && choice.value !== undefined) ? choice.value : (typeof choice === 'string' ? choice : '');
                            var t = (choice && (choice.text || choice.label)) || v;
                            var chk = cbSelected.indexOf(v) !== -1 ? ' checked' : '';
                            inputHtml += '<label class="gt-cb-label">'
                                + '<input type="checkbox" value="' + self.escapeHtml(v) + '"' + chk + '> '
                                + self.escapeHtml(t) + '</label>';
                        });
                        inputHtml += '</div>';
                    }());
                    break;
                case 'color':
                    inputHtml = '<input type="color" class="gt-edit-input gt-color-input" value="' + this.escapeHtml(currentValue || '#000000') + '">';
                    break;
                // ── end #2281 ──────────────────────────────────────────────────
                default:
                    inputHtml = '<input type="text" class="gt-edit-input" value="' + this.escapeHtml(currentValue) + '">';
            }
        }

        $field.html(inputHtml);
        $field.addClass('gt-editing');

        var $input = $field.find('.gt-edit-input');

        // Special handling for predefined choices select dropdowns
        if ($input.hasClass('gt-field-select')) {
            //console.log('GT Edit: Setting up predefined choices select handlers');

            // Auto-save on change for dropdowns
            $input.on('change', function () {
                var newValue = $(this).val();
                //console.log('GT Edit: Dropdown changed to:', newValue);
                if (newValue !== currentValue) {
                    var vd = self.validateCell ? self.validateCell(fieldId, newValue, entryId) : { valid: true };
                    if (!vd.valid) {
                        if (self.showValidationError) self.showValidationError($field, $input, vd.message);
                        return;
                    }
                    self.saveField(entryId, fieldId, newValue, $field);
                }
            });

            // Handle Escape key to cancel
            $input.on('keydown', function (e) {
                if (e.key === 'Escape') { // Escape key
                    e.preventDefault();
                    //console.log('GT Edit: Escape pressed, restoring original value');
                    $field.html(self.escapeHtml(currentValue));

                    // Restore original padding if it was a cell-based edit
                    if ($field.hasClass('gt-editable-cell') && $field.data('original-padding')) {
                        $field.css('padding', $field.data('original-padding'));
                        $field.removeData('original-padding');
                    }
                }
            });

            // Override blur handler for select dropdowns - don't auto-save, just restore if unchanged
            $input.off('blur');
            $input.on('blur', function () {
                setTimeout(function () {
                    if ($field.find('.gt-edit-input').length > 0) {
                        var newValue = $input.val();
                        if (newValue === '' || newValue === currentValue) {
                            // No selection made or same value, restore original
                            //console.log('GT Edit: Select blur with no change, restoring original');
                            $field.html(self.escapeHtml(currentValue));

                            // Restore original padding if it was a cell-based edit
                            if ($field.hasClass('gt-editable-cell') && $field.data('original-padding')) {
                                $field.css('padding', $field.data('original-padding'));
                                $field.removeData('original-padding');
                            }
                        }
                        // If a valid selection was made, the change handler already saved it
                    }
                }, 100);
            });

            // Focus the select
            setTimeout(function () {
                $input.focus();
            }, 100);

            // Don't continue with the normal input setup for selects
            return;
        }

        // ── #2281 multiselect: save JSON array on change ────────────────────
        if ($input.hasClass('gt-multiselect-input')) {
            $input.on('change', function () {
                var selected = [];
                $input.find('option:selected').each(function () {
                    selected.push($(this).val());
                });
                var newValue = JSON.stringify(selected);
                self.saveField(entryId, fieldId, newValue, $field);
            });
            $input.on('keydown', function (e) {
                if (e.key === 'Escape') {
                    e.preventDefault();
                    $field.html(self.escapeHtml(currentValue));
                    if ($field.hasClass('gt-editable-cell') && $field.data('original-padding')) {
                        $field.css('padding', $field.data('original-padding'));
                        $field.removeData('original-padding');
                    }
                }
            });
            setTimeout(function () { $input.focus(); }, 100);
            return;
        }

        // ── #2281 checkbox_group: save JSON array on change ─────────────────
        if ($input.hasClass('gt-checkbox-group-input')) {
            var collectCbValues = function () {
                var checked = [];
                $input.find('input[type="checkbox"]:checked').each(function () {
                    checked.push($(this).val());
                });
                return JSON.stringify(checked);
            };
            $input.on('change', 'input[type="checkbox"]', function () {
                self.saveField(entryId, fieldId, collectCbValues(), $field);
            });
            $input.on('keydown', function (e) {
                if (e.key === 'Escape') {
                    e.preventDefault();
                    $field.html(self.escapeHtml(currentValue));
                    if ($field.hasClass('gt-editable-cell') && $field.data('original-padding')) {
                        $field.css('padding', $field.data('original-padding'));
                        $field.removeData('original-padding');
                    }
                }
            });
            return;
        }

        // ── #2281 color: auto-save on change (browser guarantees valid hex) ─
        if ($input.hasClass('gt-color-input')) {
            $input.on('change', function () {
                var newValue = $input.val();
                if (newValue !== currentValue) {
                    self.saveField(entryId, fieldId, newValue, $field);
                }
            });
            $input.on('keydown', function (e) {
                if (e.key === 'Escape') {
                    e.preventDefault();
                    $field.html(self.escapeHtml(currentValue));
                    if ($field.hasClass('gt-editable-cell') && $field.data('original-padding')) {
                        $field.css('padding', $field.data('original-padding'));
                        $field.removeData('original-padding');
                    }
                }
            });
            $input.on('blur', function () {
                setTimeout(function () {
                    if ($field.find('.gt-edit-input').length > 0) {
                        var newValue = $input.val();
                        if (newValue !== currentValue) {
                            self.saveField(entryId, fieldId, newValue, $field);
                        } else {
                            $field.html(self.escapeHtml(currentValue));
                            if ($field.hasClass('gt-editable-cell') && $field.data('original-padding')) {
                                $field.css('padding', $field.data('original-padding'));
                                $field.removeData('original-padding');
                            }
                        }
                    }
                }, 100);
            });
            setTimeout(function () { $input.focus(); }, 100);
            return;
        }

        // Focus and select text for better experience
        setTimeout(function () {
            $input.focus();

            // #1568: setSelectionRange is not supported on all input types
            // (number, email in jsdom, color, etc.) and throws InvalidStateError
            // when the input is not part of the document anymore. Wrap in a
            // try-catch so the focus path still completes cleanly even on
            // detached or unsupported inputs.
            //
            // Special handling for different input types
            try {
                if ($input.hasClass('gt-date-input')) {
                    // For date inputs with custom format, just select all text
                    if ($input[0].setSelectionRange) {
                        $input[0].setSelectionRange(0, $input.val().length);
                    }
                } else if ($input.attr('type') === 'date') {
                    // For HTML5 date inputs, try to show the picker
                    /* c8 ignore next */
                    if ($input[0].showPicker) {
                        /* c8 ignore next */
                        $input[0].showPicker();
                    } else {
                        // Fallback: click to trigger picker on some browsers
                        /* c8 ignore next */
                        $input.trigger('click');
                    }
                } else if ($input[0].setSelectionRange && $input.attr('type') !== 'number' && $input.attr('type') !== 'email') {
                    // Don't use setSelectionRange on number or email inputs --
                    // number is not supported anywhere; email throws under
                    // jsdom and is unreliable across browsers per the HTML spec.
                    $input[0].setSelectionRange(0, $input.val().length);
                }
            } catch (e) {
                /* c8 ignore next */
                // Swallow — focus is the important side-effect; selection is
                // an extra convenience that can be skipped on input types
                // that reject it.
            }
        }, 100);

        // #2281 — client-side URL guard. Fires before saveField for url-type
        // inputs (type="url"). Returns an error message string on invalid input,
        // null when the value is valid or the input is not a URL field.
        var checkUrlValidity = function (val) {
            if ($input.hasClass('gt-url-input') && val !== '') {
                try {
                    // eslint-disable-next-line no-new
                    new URL(val);
                    return null; // valid
                } catch (e) {
                    return 'Please enter a valid URL (include https://).';
                }
            }
            return null;
        };

        // Enhanced keyboard handling
        $input.on('keydown', function (e) {
            if (e.key === 'Enter' && !e.shiftKey) { // Enter key (allow Shift+Enter for textarea)
                if (fieldType !== 'textarea' || !e.shiftKey) {
                    e.preventDefault();
                    var newValue = $input.val();
                    var urlErr = checkUrlValidity(newValue);
                    if (urlErr) {
                        if (self.showValidationError) self.showValidationError($field, $input, urlErr);
                        return;
                    }
                    var vEnter = self.validateCell ? self.validateCell(fieldId, newValue, entryId) : { valid: true };
                    if (!vEnter.valid) {
                        if (self.showValidationError) self.showValidationError($field, $input, vEnter.message);
                        return;
                    }
                    var nextTarget = self.findNextEditCell($field, 'down');
                    self.saveField(entryId, fieldId, newValue, $field);
                    if (nextTarget) self.scheduleEditOnTarget(nextTarget);
                }
            } else if (e.key === 'Tab') { // Tab key (with or without shift)
                e.preventDefault();
                var newValueT = $input.val();
                var urlErrT = checkUrlValidity(newValueT);
                if (urlErrT) {
                    if (self.showValidationError) self.showValidationError($field, $input, urlErrT);
                    return;
                }
                var vTab = self.validateCell ? self.validateCell(fieldId, newValueT, entryId) : { valid: true };
                if (!vTab.valid) {
                    if (self.showValidationError) self.showValidationError($field, $input, vTab.message);
                    return;
                }
                var direction = e.shiftKey ? 'prev' : 'next';
                var target = self.findNextEditCell($field, direction);
                self.saveField(entryId, fieldId, newValueT, $field);
                if (target) self.scheduleEditOnTarget(target);
            } else if (e.key === 'Escape') { // Escape key
                e.preventDefault();
                $field.html(self.escapeHtml(currentValue));

                // Restore original padding if it was a cell-based edit
                if ($field.hasClass('gt-editable-cell') && $field.data('original-padding')) {
                    $field.css('padding', $field.data('original-padding'));
                    $field.removeData('original-padding');
                }
            }
        });

        // Handle blur event - save changes automatically but with validation
        $input.on('blur', function () {
            setTimeout(function () {
                if ($field.find('.gt-edit-input').length > 0) {
                    var newValue = $input.val();
                    var originalValue = $field.data('original-value') || '';

                    // Only save if value actually changed and is not empty (unless original was empty)
                    if (newValue !== originalValue && (newValue !== '' || originalValue !== '')) {
                        // #2281 — URL guard on blur
                        var urlErrBlur = checkUrlValidity(newValue);
                        if (urlErrBlur) {
                            if (self.showValidationError) self.showValidationError($field, $input, urlErrBlur);
                            return;
                        }
                        var vBlur = self.validateCell ? self.validateCell(fieldId, newValue, entryId) : { valid: true };
                        if (!vBlur.valid) {
                            if (self.showValidationError) self.showValidationError($field, $input, vBlur.message);
                            return;
                        }
                        //console.log('GT Edit: Blur save - New:', newValue, 'Original:', originalValue);
                        self.saveField(entryId, fieldId, newValue, $field);
                    } else {
                        //console.log('GT Edit: Blur cancelled - no meaningful change detected');
                        // Restore original value if no meaningful change
                        $field.html(self.escapeHtml(originalValue));

                        // Restore original padding if it was a cell-based edit
                        if ($field.hasClass('gt-editable-cell') && $field.data('original-padding')) {
                            $field.css('padding', $field.data('original-padding'));
                            $field.removeData('original-padding');
                        }
                    }
                }
            }, 100);
        });
    };

})(window);

/* ===== assets/js/frontend/bind-events.js ===== */
/**
 * TableCrafter — frontend/bind-events.js
 *
 * Global event wiring for a GravityTable instance. Tenth slice
 * under #833. One method, ~220 lines.
 *
 * Public surface (attached directly to GravityTable.prototype):
 *
 *   - bindEvents()
 *
 * Wires every top-level event listener: toolbar button clicks,
 * filter input changes, search input, sort handlers, pagination,
 * the inline-edit / detail-popup delegated handlers. Called once
 * from init(). All listeners route through this/self so callees
 * in other modules (saveField, editField, deleteEntry, etc.) work
 * via the prototype.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    GravityTable.prototype.bindEvents = function () {
        var self = this;
        var $wrapper = $('#' + this.wrapperId);

        // Remove any stale handlers from a previous init cycle (re-mount safety)
        $wrapper.off('.gt-table');

        // #599 slice 3 — Cascading filter dropdown handler. When the
        // visitor changes a filter input that has data-gt-cascade-parent,
        // fetch valid child options for the chosen value and update the
        // child filter's placeholder to surface the cascade. Behavior
        // intentionally minimal: doesn't enforce the child's value (the
        // user can still type anything); it just hints at what's
        // available given the parent. Customers asking for full select-
        // based UX get slice 4.
        $wrapper.on('input.gt-table change.gt-table', '.gt-per-col-filter[data-gt-cascade-parent]', function () {
            var $parent = $(this);
            var parentValue = $parent.val();
            var parentField = $parent.attr('data-gt-cascade-parent');
            var childField = $parent.attr('data-gt-cascade-child');
            if (!parentField || !childField) return;

            var $child = $wrapper.find('.gt-per-col-filter[data-field="' + childField + '"]').first();
            if (!$child.length) return;

            // Reset placeholder when parent is empty.
            if (parentValue === '') {
                $child.attr('placeholder', $child.data('gt-original-placeholder') || childField);
                return;
            }
            // Stash the original placeholder once.
            if (!$child.data('gt-original-placeholder')) {
                $child.data('gt-original-placeholder', $child.attr('placeholder') || childField);
            }

            $.post(self.config.ajax_url || (typeof gtTableData !== 'undefined' && gtTableData.ajax_url) || '/wp-admin/admin-ajax.php', {
                action: 'gt_cascading_filter_options',
                table_id: self.config.table_id,
                parent_field: parentField,
                parent_value: parentValue,
                child_field: childField
            }, function (resp) {
                if (!resp || !resp.success) return;
                var options = (resp.data && resp.data.options) || [];
                if (options.length === 0) {
                    $child.attr('placeholder', 'No matches for "' + parentValue + '"');
                } else {
                    $child.attr('placeholder', options.length + ' option' + (options.length === 1 ? '' : 's') + ' (e.g. ' + options.slice(0, 3).join(', ') + (options.length > 3 ? '…' : '') + ')');
                }
            }, 'json');
        });

        // #1679 — per-column filter inputs. The .gt-per-col-filter row was
        // dead UI: nothing collected the typed values or reloaded the table
        // (only the cascade placeholder hint above was wired). Collect every
        // per-column value into this.filters and reload via the existing
        // server text-filter path. Debounced on input (so we don't fire a
        // request per keystroke); immediate on change/blur and Enter.
        var gtPerColTimer = null;
        function gtRunPerColFilters() {
            if (typeof self.applyPerColumnFilters === 'function') {
                self.applyPerColumnFilters();
            }
        }
        $wrapper.on('input.gt-table', '.gt-per-col-filter', function () {
            if (gtPerColTimer) { clearTimeout(gtPerColTimer); }
            gtPerColTimer = setTimeout(gtRunPerColFilters, 300);
        });
        $wrapper.on('change.gt-table', '.gt-per-col-filter', function () {
            if (gtPerColTimer) { clearTimeout(gtPerColTimer); }
            gtRunPerColFilters();
        });
        $wrapper.on('keydown.gt-table', '.gt-per-col-filter', function (e) {
            if (e.which === 13 || e.key === 'Enter') {
                if (gtPerColTimer) { clearTimeout(gtPerColTimer); }
                gtRunPerColFilters();
            }
        });

        // #568 slice 4 — Click-to-filter cell drill-down (URL + AJAX integration).
        // Visitor clicks a cell value in an enabled column → a chip appears,
        // the URL ?gt_df= parameter is updated, and the table reloads from
        // the server with the new filter applied. Survives pagination.
        var drilldownCols = (self.config && self.config.drilldown_columns) || [];
        if (drilldownCols.length) {
            function $drilldownChipsContainer() {
                var $existing = $wrapper.find('.gt-drilldown-chips').first();
                if ($existing.length) return $existing;
                var $c = $('<div class="gt-drilldown-chips" role="status" aria-live="polite"></div>');
                var $table = $wrapper.find('table.gt-table, .gt-table-element').first();
                if ($table.length) { $table.before($c); } else { $wrapper.prepend($c); }
                return $c;
            }

            function escAttrText(s) {
                return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
            }

            function renderDrilldownChips() {
                var $c = $drilldownChipsContainer();
                if (!self.drilldownFilters.length) { $c.empty().hide(); return; }
                var html = '';
                $.each(self.drilldownFilters, function (i, f) {
                    var safeCol = String(f.col).replace(/[^A-Za-z0-9_.]/g, '');
                    var label = $('<div>').text(f.col + ' = ' + f.value).html();
                    html += '<span class="gt-drilldown-chip" data-col="' + safeCol + '" data-value="' + escAttrText(f.value) + '">';
                    html += '<span class="gt-drilldown-chip-label">' + label + '</span>';
                    html += '<button type="button" class="gt-drilldown-chip-remove" aria-label="Remove filter">×</button>';
                    html += '</span>';
                });
                $c.html(html).show();
            }

            function toggleDrilldownFilter(col, value) {
                var found = -1;
                for (var i = 0; i < self.drilldownFilters.length; i++) {
                    if (self.drilldownFilters[i].col === col && self.drilldownFilters[i].value === value) {
                        found = i; break;
                    }
                }
                if (found >= 0) {
                    self.drilldownFilters.splice(found, 1);
                } else {
                    self.drilldownFilters.push({ col: col, value: value });
                }
                renderDrilldownChips();
                self.updateDrilldownUrlState();
                self.currentPage = 1; // reset to first page on new filter
                self.loadEntries();
            }

            // Initial render if filters were seeded from URL/config
            if (self.drilldownFilters.length) {
                renderDrilldownChips();
            }

            // Cell click delegate.
            $wrapper.on('click.gt-drilldown', 'tbody td', function (e) {
                var $td = $(this);
                if ($(e.target).closest('a, button, input, textarea, select, label, .gt-editable-cell, .gt-toggle-switch, .gt-actions-cell, .gt-checkbox-cell').length > 0) {
                    return;
                }
                var col = '';
                var classes = ($td.attr('class') || '').split(/\s+/);
                for (var i = 0; i < classes.length; i++) {
                    if (classes[i].indexOf('gt-column-') === 0) {
                        col = classes[i].substring('gt-column-'.length);
                        break;
                    }
                }
                if (!col || drilldownCols.indexOf(col) === -1) return;
                var value = String($td.text() || '').trim();
                if (value === '') return;
                e.preventDefault();
                e.stopPropagation();
                toggleDrilldownFilter(col, value);
            });

            // Chip removal.
            $wrapper.on('click.gt-drilldown', '.gt-drilldown-chip-remove', function (e) {
                e.preventDefault(); e.stopPropagation();
                var $chip = $(this).closest('.gt-drilldown-chip');
                var col = $chip.attr('data-col');
                var value = $chip.attr('data-value');
                if (col && value !== undefined) { toggleDrilldownFilter(col, value); }
            });
        }

        // Row-link interaction (#567) — click + auxclick + keyboard activation
        // moved to assets/js/frontend/a11y-keyboard.js (#839). bindRowLinkEvents
        // wires the three handlers in one call.
        self.bindRowLinkEvents($wrapper);

        // Length selector + Pagination handlers moved to assets/js/frontend/pagination.js (#835).
        // bindPaginationEvents wires .gt-length-select change + .gt-prev-page click +
        // .gt-next-page click in one call.
        self.bindPaginationEvents($wrapper);

        // Search controls moved to assets/js/frontend/search.js (#834 slice 1).
        self.bindSearchEvents($wrapper);

        // Prev/Next page click handlers moved to assets/js/frontend/pagination.js (#835).

        // Sort handler + multi-column state machine moved to assets/js/frontend/sort.js (#834 slice 3).
        self.bindSortEvents($wrapper);

        // #1621 — client-side sort for computed columns (no DB column,
        // so the server sort path can't see them). typeof guard keeps
        // harnesses without the module on the old path.
        if (typeof self.bindComputedSortEvents === 'function') {
            self.bindComputedSortEvents($wrapper);
        }

        // Selection + bulk-action handlers moved to assets/js/frontend/selection.js (#836).
        // bindSelectionEvents wires both the .gt-select-all change and the
        // .gt-bulk-action-btn click in one call.
        self.bindSelectionEvents($wrapper);

        // #1745 — show/hide Fill Column button based on row selection.
        $wrapper.on('change', '.gt-entry-checkbox, .gt-select-all', function () {
            var anyChecked = $wrapper.find('.gt-entry-checkbox:checked').length > 0;
            $wrapper.find('.gt-bulk-fill-btn').toggle(anyChecked);
        });

        // #1745 — open fill modal when button clicked.
        $wrapper.find('.gt-bulk-fill-btn').on('click', function () {
            if (typeof self.openBulkFillModal === 'function') {
                var ids = self.getSelectedEntryIds ? self.getSelectedEntryIds() : [];
                self.openBulkFillModal(ids);
            }
        });

        // Export controls
        $wrapper.find('.gt-export-btn').on('click', function (e) {
            e.preventDefault();
            e.stopPropagation();
            var $dropdown = $(this).closest('.gt-export-dropdown');
            $dropdown.toggleClass('open');

            // Close other open dropdowns
            $('.gt-export-dropdown').not($dropdown).removeClass('open');
        });

        $wrapper.find('.gt-export-option').on('click', function (e) {
            e.preventDefault();
            var format = $(this).data('format');
            self.exportTable(format);

            // Close dropdown
            $(this).closest('.gt-export-dropdown').removeClass('open');
        });

        // #1680 — the visible-rows export actions (Copy / CSV / Excel / PDF)
        // now live inside the Export dropdown; their actual handlers are wired
        // by initToolbarExport() via the preserved gt-toolbar-*-btn classes.
        // Here we just close the dropdown after the action runs. Delegated so
        // it survives re-renders.
        $wrapper.on('click', '.gt-export-menu-item', function () {
            $(this).closest('.gt-export-dropdown').removeClass('open');
        });

        // Advanced filter panel handlers moved to assets/js/frontend/filter-panel.js (#834 slice 2).
        self.bindFilterPanelEvents($wrapper);

        // 4.7.57: Text-filter typeahead. Each .gt-text-filter input becomes a search-with-suggestions
        // bound to distinct existing values for that column. Helps admins (a) discover what's already
        // in the data without remembering exact spelling and (b) clean up messy free-text columns by
        // finding variants/typos and inline-editing rows to a canonical value.
        self.initTextFilterTypeaheads($wrapper);

        // Handle delete action clicks
        $wrapper.on('click', '.gt-delete-action', function (e) {
            e.preventDefault();
            e.stopPropagation();

            var $row = $(this).closest('tr');
            var entryId = $row.data('entry-id');

            if (!entryId) {
                console.error('GT: No entry ID found for delete action');
                return;
            }

            // Show confirmation dialog
            if (confirm('Are you sure you want to permanently delete this entry? This action cannot be undone.')) {
                self.deleteEntry(entryId, $row);
            }
        });

        // Close export dropdowns within this wrapper when clicking outside — scoped to
        // $wrapper so it cannot capture clicks that belong to other plugins (#435)
        $wrapper.on('click.gt-table', function (e) {
            if (!$(e.target).closest('.gt-export-dropdown').length) {
                $wrapper.find('.gt-export-dropdown').removeClass('open');
            }
        });
    };

})(window);

/* ===== assets/js/frontend/bind-entry-events.js ===== */
/**
 * TableCrafter — frontend/bind-entry-events.js
 *
 * Per-row event wiring. Eleventh slice under #833. One method,
 * ~186 lines.
 *
 * Public surface (attached directly to GravityTable.prototype):
 *
 *   - bindEntryEvents()
 *
 * Delegates per-row events on the tbody: action button clicks
 * (view / edit / delete / history / WC-create), row-link clicks
 * (#567), inline-edit cell clicks, file-upload triggers.
 * Called once from init() (or re-called after renderEntries swaps
 * out the tbody, namespaced for clean teardown).
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    GravityTable.prototype.bindEntryEvents = function () {
        var self = this;
        var $wrapper = $('#' + this.wrapperId);

        // #556 slice 3: chevron click toggles the sibling gt-detail-row.
        // Delegated so it stays bound across re-renders. Bound once via
        // $wrapper.data('gt-detail-bound') because bindEntryEvents fires on
        // every renderEntries — without the guard we'd stack N handlers.
        if (!$wrapper.data('gt-detail-bound')) {
            $wrapper.on('click', '.gt-detail-toggle', function (e) {
                e.preventDefault();
                e.stopPropagation();
                var $btn = $(this);
                var targetId = $btn.attr('aria-controls');
                var $detail = $wrapper.find('#' + targetId);
                if (!$detail.length) { return; }
                var expanded = $btn.attr('aria-expanded') === 'true';
                if (expanded) {
                    $detail.attr('hidden', 'hidden').prop('hidden', true);
                    $btn.attr('aria-expanded', 'false').attr('aria-label', 'Show row details');
                } else {
                    $detail.removeAttr('hidden').prop('hidden', false);
                    $btn.attr('aria-expanded', 'true').attr('aria-label', 'Hide row details');
                }
            });
            // Keyboard activation (Enter / Space) — buttons already do this
            // natively, but stopPropagation above means we also want to keep
            // it from bubbling into the row-click handler.
            $wrapper.on('keydown', '.gt-detail-toggle', function (e) {
                if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
                    e.stopPropagation();
                }
            });
            $wrapper.data('gt-detail-bound', true);
        }

        // Toggle switch click: flip boolean value and save via AJAX (#325)
        $wrapper.on('click keydown', '.gt-toggle-switch', function (e) {
            if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
            e.preventDefault();
            e.stopPropagation();
            var $switch   = $(this);
            var entryId   = $switch.data('entry-id');
            var fieldId   = $switch.data('field-id');
            var curVal    = parseInt($switch.data('toggle-value'), 10);
            var newVal    = curVal ? 0 : 1;
            $switch.data('toggle-value', newVal)
                   .toggleClass('gt-toggle-on', newVal === 1)
                   .toggleClass('gt-toggle-off', newVal === 0)
                   .attr('aria-checked', newVal ? 'true' : 'false');
            var $cell = $switch.closest('td');
            self.saveField(entryId, fieldId, String(newVal), $cell);
        });

        // Row click for inline editing option (but not on clickable elements)
        $wrapper.find('tbody tr').on('click', function (e) {
            // Don't trigger if clicking on buttons, checkboxes, or editable fields
            if ($(e.target).is('button, input, .gt-editable-field, .gt-edit-input, .gt-save-btn, .gt-cancel-btn, .gt-editable-cell')) {
                return;
            }

            var $row = $(this);
            var entryId = $row.data('entry-id');
            var $clickedCell = $(e.target).closest('td');

            // Check if clicked on a readonly cell
            if ($clickedCell.hasClass('gt-readonly-cell')) {
                // Only show readonly message if frontend editing is enabled
                // If editing is disabled globally, no message is needed
                if (self.config.enable_frontend_editing) {
                    self.showReadonlyIndicator($clickedCell);
                }
                return;
            }

            // Remove any existing edit indicators
            $wrapper.find('.gt-edit-indicator').remove();
            $wrapper.find('tbody tr').removeClass('gt-row-selected');

            // Add selected class
            $row.addClass('gt-row-selected');

            // Show edit indicator/tooltip
            self.showEditIndicator($row, entryId);
        });

        // Editable fields
        // Handle clicks on editable cells (spreadsheet-like behavior)
        $wrapper.find('.gt-editable-cell').on('click', function (e) {
            e.stopPropagation(); // Prevent row click

            // Check if we clicked on an existing input/select - don't re-edit
            if ($(e.target).is('input, select, option')) {
                //console.log('GT Frontend: Click on input/select, not re-editing field');
                return;
            }

            // Check if this cell already has an active input/select
            if ($(this).find('.gt-edit-input').length > 0) {
                //console.log('GT Frontend: Cell already has active input, focusing it instead');
                $(this).find('.gt-edit-input').focus();
                return;
            }

            // Check if frontend editing is enabled
            if (!self.config.enable_frontend_editing) {
                //console.log('GT Frontend: Frontend editing is disabled');
                return;
            }

            self.editField($(this));
        });

        // Keep backward compatibility for existing gt-editable-field elements
        $wrapper.find('.gt-editable-field').on('click', function (e) {
            e.stopPropagation(); // Prevent row click

            // Check if frontend editing is enabled
            if (!self.config.enable_frontend_editing) {
                //console.log('GT Frontend: Frontend editing is disabled');
                return;
            }

            self.editField($(this));
        });

        // #1921 — Mobile touch-tap: fast-edit on tap without the 300ms synthetic-click
        // delay on older iOS. A movement guard (>10px) prevents scroll from opening an
        // editor. preventDefault on the touchend cancels the subsequent synthetic click
        // so editField is not called twice when touch + click both fire.
        $wrapper.find('.gt-editable-cell, .gt-editable-field').on('touchstart', function (e) {
            var t = e.originalEvent.changedTouches && e.originalEvent.changedTouches[0];
            if (t) {
                $(this).data('gt-touch-start', { x: t.clientX, y: t.clientY });
            }
        }).on('touchend', function (e) {
            var $cell = $(this);
            var start = $cell.data('gt-touch-start');
            if (!start) { return; }
            var t = e.originalEvent.changedTouches && e.originalEvent.changedTouches[0];
            $cell.removeData('gt-touch-start');
            if (!t) { return; }
            var dx = Math.abs(t.clientX - start.x);
            var dy = Math.abs(t.clientY - start.y);
            if (dx > 10 || dy > 10) { return; } // scroll gesture — do not edit
            if ($(e.target).is('input, select, option')) { return; }
            if ($cell.find('.gt-edit-input').length > 0) {
                $cell.find('.gt-edit-input').focus();
                e.preventDefault();
                return;
            }
            if (!self.config.enable_frontend_editing) { return; }
            e.preventDefault(); // suppress synthetic click
            e.stopPropagation();
            self.editField($cell);
        });

        // Action buttons (edit button removed - using inline editing instead)

        $wrapper.find('.gt-delete-action').on('click', function (e) {
            e.stopPropagation(); // Prevent row click
            var entryId = $(this).data('entry-id');
            if (confirm('Are you sure you want to delete this entry?')) {
                self.deleteEntry(entryId);
            }
        });

        // View-detail click handlers moved to assets/js/frontend/detail-popup.js (#837).
        self.bindDetailViewEvents($wrapper);

        // #1747 — duplicate entry (Pro).
        $wrapper.on('click', '.gt-duplicate-action', function (e) {
            e.preventDefault();
            e.stopPropagation();
            var entryId = $(this).data('entry-id');
            if (typeof self.duplicateEntry === 'function') {
                self.duplicateEntry(entryId);
            }
        });

        // History action
        $wrapper.on('click', '.gt-history-action', function (e) {
            e.preventDefault();
            e.stopPropagation();
            var entryId = $(this).data('entry-id');
            self.viewEntryHistory(entryId);
        });

        // WooCommerce create-product action
        $wrapper.on('click', '.gt-wc-create-action', function (e) {
            e.preventDefault();
            e.stopPropagation();
            var entryId = $(this).data('entry-id');
            self.createWooCommerceProduct(entryId, $(this));
        });

        // Edit row action
        $wrapper.on('click', '.gt-edit-action', function (e) {
            e.preventDefault();
            e.stopPropagation();
            var $row = $(this).closest('tr');
            var entryId = $(this).data('entry-id');
            
            // Remove any existing edit indicators
            $wrapper.find('.gt-edit-indicator').remove();
            $wrapper.find('tbody tr').removeClass('gt-row-selected');

            // Add selected class and show indicator
            $row.addClass('gt-row-selected');
            self.showEditIndicator($row, entryId);
        });

        // Click outside to remove edit indicators (namespaced per table to allow clean teardown)
        $(document).on('click.gtTable' + self.wrapperId, function (e) {
            if (!$(e.target).closest('#' + self.wrapperId).length) {
                $wrapper.find('.gt-edit-indicator').remove();
                $wrapper.find('tbody tr').removeClass('gt-row-selected');
            }
        });
    };

})(window);

/* ===== assets/js/frontend/scroll-indicators.js ===== */
/**
 * TableCrafter — frontend/scroll-indicators.js
 *
 * Horizontal scroll affordance overlay. Twelfth slice under #833.
 * One method, ~370 lines — second-largest single-method extraction
 * after #832 slice 20's responsive block.
 *
 * Public surface (attached directly to GravityTable.prototype):
 *
 *   - setupScrollIndicators() — install scroll-shadow gradients on
 *     the .gt-table-content wrapper, wire scroll listeners to
 *     toggle the .gt-scrollable-left / .gt-scrollable-right classes,
 *     re-evaluate after entries load via the gt-entries-loaded
 *     custom event.
 *
 * Called once from init().
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    GravityTable.prototype.setupScrollIndicators = function () {
        var self = this;
        var $wrapper = $('#' + this.wrapperId);
        var $tableContent = $wrapper.find('.gt-table-content');
        var $table = $wrapper.find('.gt-table');

        if ($tableContent.length === 0 || $table.length === 0) {
            return;
        }

        // Only show scroll indicators on mobile or when responsive_table is enabled
        // NOT for desktop tables in normal view
        var windowWidth = $(window).width();
        var isMobile = windowWidth <= 768;
        var shouldShowScrollIndicators = isMobile || this.config.responsive_table;

        if (!shouldShowScrollIndicators) {
            //console.log('GT Frontend: Skipping scroll indicators for desktop view');
            return;
        }

        function updateScrollIndicators() {
            var contentWidth = $tableContent.width();
            var tableWidth = $table.width();
            var scrollLeft = $tableContent.scrollLeft();
            var maxScroll = tableWidth - contentWidth;

            // Remove existing classes and hints
            $tableContent.removeClass('no-scroll has-scroll scroll-left scroll-right');
            $tableContent.find('.gt-scroll-hint').remove();

            if (maxScroll <= 0) {
                // Table fits completely, no scroll needed
                $tableContent.addClass('no-scroll');
            } else {
                // Table is wider than container - just add scroll hint, no shadows
                $tableContent.addClass('has-scroll');

                // Add scroll hint if user hasn't interacted yet (but not on mobile)
                if (!$tableContent.hasClass('scroll-interacted') && scrollLeft === 0 && window.innerWidth > 480) {
                    $tableContent.append('<div class="gt-scroll-hint">← Scroll to see more →</div>');
                }
            }
        }

        // Initial check
        updateScrollIndicators();

        // Update on scroll
        $tableContent.on('scroll', function () {
            // Mark as interacted with on first scroll
            if (!$tableContent.hasClass('scroll-interacted')) {
                $tableContent.addClass('scroll-interacted');
            }
            updateScrollIndicators();
        });

        // Update on window resize (namespaced so each table instance can be cleaned up independently)
        $(window).on('resize.gtScroll.' + self.wrapperId, function () {
            setTimeout(updateScrollIndicators, 100);
        });

        // Update after entries are loaded
        $(document).on('gt-entries-loaded-' + this.wrapperId, updateScrollIndicators);
    };


    /**
     * Extract and return the lowercase file extension from a URL or filename.
     * Returns empty string if no extension can be determined.
     *
     * @param  {string} url  File URL or filename.
     * @return {string}      Lowercase extension (e.g. 'pdf', 'jpg') or ''.
     */
    // getFileExtension removed (#832 slice 16) — dead code; the only
    // caller (renderFileUploadCell) moved to detail-popup.js in #837 with
    // its own inline extension parsing, leaving this method orphaned.

    /**
     * Normalise a raw field value to a boolean 0/1 for toggle columns (#325).
     * '1', 'true', 'yes' (case-insensitive) → 1; everything else → 0.
     */
    // normalizeToggleValue moved to assets/js/frontend/util.js (#841).

    /**
     * Render a fileupload field value as <img> (for images) or <a> links (for other files).
     * Handles single URLs, comma-separated URLs, and JSON-encoded arrays.
     */
    // renderFileUploadCell moved to assets/js/frontend/detail-popup.js (#837).

    // escapeRegex and escapeHtml moved to assets/js/frontend/util.js (#841).

    // applyRowExpiry (#501) + applyAutoMerge (#518) moved to
    // assets/js/frontend/post-render-gates.js (#832 slice 18).

    // Date formatting utilities
    // applyConditionalFormatting / getColumnIndex / getCellValue / evaluateCondition /
    // applyFormattingAction moved to assets/js/frontend/conditional-format.js (#838 —
    // third child of #830). Object.assign attachment keeps the public surface
    // unchanged so existing call sites (self.applyConditionalFormatting(),
    // self.evaluateCondition(...), etc.) work as before.

    // formatDate moved to assets/js/frontend/util.js (#841).

    // parseDateInput moved to assets/js/frontend/util.js (#832 slice 16) —
    // joins the existing date helper family.

    // Initialize add new entry functionality
    $(document).ready(function () {
        // Prevent duplicate event handlers
        if (window.gtAddNewInitialized) {
            //console.log('GT Add New: Already initialized, skipping');
            /* c8 ignore next */
            return;
        }
        window.gtAddNewInitialized = true;

        // Listen for successful form submission from iframe
        window.addEventListener('message', function(event) {
            if (event.data && event.data.gtEvent === 'form_submitted') {
                if (typeof tb_remove === 'function') {
                    tb_remove();
                }
                // Reload the page to show the new entry
                window.location.reload();
            }
        });

        // Handle Add New Entry button click
        $(document).on('click.gtAddNew', '.gt-add-new-btn', function (e) {
            e.preventDefault();
            var $button = $(this);
            var formId = $button.data('form-id');

            if (!formId) {
                console.error('GT Add New: No form ID found for add new entry');
                return;
            }

            // Get the correct AJAX URL
            var ajax_url = (window.gravityTables && window.gravityTables.ajax_url) || '/wp-admin/admin-ajax.php';

            // Calculate dimensions
            var width = 600;
            var height = 500;
            if (window.innerWidth) {
                width = Math.min(600, window.innerWidth - 40);
                height = Math.min(500, window.innerHeight - 40);
            }

            // Get nonce for AJAX request - try multiple sources
            var $tableWrapper = $button.closest('.gt-table-wrapper');
            var nonce = $tableWrapper.data('nonce') ||
                (window.gtTableData && window.gtTableData[$tableWrapper.attr('id')] && window.gtTableData[$tableWrapper.attr('id')].nonce) ||
                (window.gravityTables && window.gravityTables.nonce);

            // Get table_id for permission check
            var tableWrapperConfig = window.gtTableData && window.gtTableData[$tableWrapper.attr('id')];
            var tableDbId = (tableWrapperConfig && tableWrapperConfig.table_id) || 0;

            // Construct ThickBox URL - Let CSS handle dimensions
            var url = ajax_url + '?action=gt_get_form_html&form_id=' + formId + '&nonce=' + nonce + '&table_id=' + tableDbId + '&raw=1&TB_iframe=true';

            // Debug logging
            // console.log('GT Add New: Opening Thickbox with URL:', url);

            // Open ThickBox
            tb_show('Add New Entry', url);

            // Override Thickbox's inline styles with proper dimensions
            setTimeout(function () {
                var $window = $('#TB_window');
                var $iframe = $('#TB_iframeContent');

                if ($window.length) {
                    // Force modal sizing
                    $window.css({
                        'width': '80%',
                        'max-width': '900px',
                        'height': 'auto',
                        'top': '30px',
                        'bottom': '30px',
                        'left': '50%',
                        'margin-left': '0',
                        'margin-top': '0',
                        'transform': 'translateX(-50%)',
                        'overflow-y': 'auto'
                    });
                }

                if ($iframe.length) {
                    // Force iframe sizing
                    $iframe.css({
                        'width': '100%',
                        'height': 'calc(100vh - 100px)',
                        'min-height': '600px'
                    });
                }

                // console.log('GT Add New: Thickbox dimensions overridden');
            }, 100);

            // Verify iframe was created
            setTimeout(function () {
                var $iframe = $('#TB_iframeContent');
                // console.log('GT Add New: Iframe exists:', $iframe.length > 0);
                if ($iframe.length > 0) {
                    // console.log('GT Add New: Iframe src:', $iframe.attr('src'));
                    // console.log('GT Add New: Iframe display:', $iframe.css('display'));
                    // console.log('GT Add New: Iframe visibility:', $iframe.css('visibility'));
                    // console.log('GT Add New: Iframe height:', $iframe.css('height'));
                }
            }, 500);
        });

        // Obsolete custom modal functions removed (replaced by ThickBox integration)
        // Removed in #1560: orphan showNotification helper had no callers anywhere
        // in the codebase. The user-facing notification surface lives in
        // table-utils.js (showMessage) and edit-history.js (toast lifecycle).
    });

    // Export-close handler is now wrapper-scoped inside bindEvents() to avoid
    // capturing all document clicks (#435).

    // Initialize date filter functionality when DOM is ready
    $(document).ready(function () {
        // Handle date input synchronization (HTML5 date input with text display)
        $(document).on('change', '.gt-date-html5', function () {
            var $dateInput = $(this);
            var $textDisplay = $dateInput.siblings('.gt-date-display');
            var dateValue = $dateInput.val();

            if (dateValue) {
                // Parse the date string directly to avoid timezone issues
                // dateValue is in format "YYYY-MM-DD"
                var parts = dateValue.split('-');
                var year = parts[0];
                var month = parts[1];
                var day = parts[2];

                // Format as MM/DD/YYYY for display
                var formattedDate = month + '/' + day + '/' + year;
                $textDisplay.val(formattedDate);
            } else {
                $textDisplay.val('');
            }

            // Auto-apply filters when date changes
            var $table = $dateInput.closest('.gt-table-wrapper');
            if ($table.length && window.GravityTable) {
                var tableId = $table.attr('id');
                if (tableId && window.GravityTable.instances && window.GravityTable.instances[tableId]) {
                    window.GravityTable.instances[tableId].applyFilters();
                }
            }
        });

        // Handle text display clicks to show HTML5 date picker
        $(document).on('click focus', '.gt-date-display', function () {
            var $textDisplay = $(this);
            var $dateInput = $textDisplay.siblings('.gt-date-html5');
            $dateInput.focus();
            if ($dateInput[0].showPicker) {
                $dateInput[0].showPicker();
            } else {
                $dateInput.click();
            }
        });

        // Handle wrapper clicks to show date picker
        $(document).on('click.gtDateFilter', '.gt-date-input-wrapper', function (e) {
            var $wrapper = $(this);
            var $dateInput = $wrapper.find('.gt-date-html5');
            if (e.target === $wrapper[0] || $(e.target).hasClass('gt-date-display')) {
                e.preventDefault();
                $dateInput.focus();
                if ($dateInput[0].showPicker) {
                    $dateInput[0].showPicker();
                } else {
                    $dateInput.click();
                }
            }
        });

        // Handle date preset buttons
        $(document).on('click.gtDateFilter', '.gt-date-preset', function () {
            var $preset = $(this);
            var presetType = $preset.data('preset');
            var $dateFilter = $preset.closest('.gt-date-filter');
            var startDateStr, endDateStr;

            // Remove active class from other presets
            $dateFilter.find('.gt-date-preset').removeClass('active');
            $preset.addClass('active');

            // Get today's date in simple format without timezone conversion
            var now = new Date();
            var todayYear = now.getFullYear();
            var todayMonth = now.getMonth() + 1; // getMonth() returns 0-11
            var todayDay = now.getDate();

            // Calculate date ranges based on preset (no timezone conversion)
            switch (presetType) {
                case 'today':
                    startDateStr = endDateStr = todayYear + '-' +
                        todayMonth.toString().padStart(2, '0') + '-' +
                        todayDay.toString().padStart(2, '0');
                    break;
                case 'week':
                    // Get start of week (Sunday)
                    var dayOfWeek = now.getDay(); // 0 = Sunday, 1 = Monday, etc.
                    var startOfWeekDay = todayDay - dayOfWeek;
                    var startOfWeekDate = new Date(todayYear, todayMonth - 1, startOfWeekDay);

                    startDateStr = startOfWeekDate.getFullYear() + '-' +
                        (startOfWeekDate.getMonth() + 1).toString().padStart(2, '0') + '-' +
                        startOfWeekDate.getDate().toString().padStart(2, '0');
                    endDateStr = todayYear + '-' +
                        todayMonth.toString().padStart(2, '0') + '-' +
                        todayDay.toString().padStart(2, '0');
                    break;
                case 'month':
                    // Start of current month
                    startDateStr = todayYear + '-' +
                        todayMonth.toString().padStart(2, '0') + '-01';
                    endDateStr = todayYear + '-' +
                        todayMonth.toString().padStart(2, '0') + '-' +
                        todayDay.toString().padStart(2, '0');
                    break;
            }

            // Set the date inputs - handle both single and range dates
            if (startDateStr) {
                // Check if it's a range filter (has from/to) or single date
                var $fromInput = $dateFilter.find('.gt-date-from-html5');
                var $singleInput = $dateFilter.find('.gt-date-html5:not(.gt-date-from-html5):not(.gt-date-to-html5)');

                if ($fromInput.length > 0) {
                    // Range filter
                    $fromInput.val(startDateStr).trigger('change');
                } else if ($singleInput.length > 0) {
                    // Single date filter
                    $singleInput.val(startDateStr).trigger('change');
                }
            }

            if (endDateStr) {
                var $toInput = $dateFilter.find('.gt-date-to-html5');
                if ($toInput.length > 0) {
                    // Only set end date if we have a "to" field (range filter)
                    $toInput.val(endDateStr).trigger('change');
                }
            }
        });

        // Multi-select dropdowns are now handled natively by the browser
    });

})(window);

/* ===== assets/js/frontend/date-inputs.js ===== */
/**
 * TableCrafter — frontend/date-inputs.js
 *
 * Date picker input wiring. Thirteenth slice under #833. One
 * method, ~158 lines.
 *
 * Public surface (attached directly to GravityTable.prototype):
 *
 *   - setupDateInputs() — wires the html5 <input type="date"> +
 *     display-format mirror inputs, preset chip clicks ("today",
 *     "yesterday", "last 7", etc.), range from/to validation,
 *     and the format-conversion helpers (parseDateInput / formatDate).
 *
 * Called once from init().
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    GravityTable.prototype.setupDateInputs = function () {
        var self = this;
        var $wrapper = $('#' + this.wrapperId);

        // Setup date input wrappers with HTML5 date picker overlay
        $wrapper.find('.gt-date-input-wrapper').each(function () {
            var $wrapper = $(this);
            var $html5Input = $wrapper.find('.gt-date-html5');
            var $displayInput = $wrapper.find('.gt-date-display');
            var $filterDiv = $wrapper.closest('.gt-date-filter');
            var dateFormat = $filterDiv.data('date-format') || self.config.date_format || 'm/d/Y';

            // When HTML5 date input changes, update the display input
            $html5Input.on('change', function () {
                var htmlValue = $(this).val(); // YYYY-MM-DD format
                if (htmlValue) {
                    var formattedValue = self.convertHtml5DateToFormat(htmlValue, dateFormat);
                    $displayInput.val(formattedValue);
                    // console.log('GT Frontend: Date selected - HTML5:', htmlValue, 'Formatted:', formattedValue);
                } else {
                    $displayInput.val('');
                }
                // Auto-apply filters when date changes
                self.applyFilters();
            });

            // When display input is clicked, trigger the HTML5 input
            $displayInput.on('click', function (e) {
                e.preventDefault();
                $html5Input.focus();
                if ($html5Input[0].showPicker) {
                    $html5Input[0].showPicker();
                } else {
                    $html5Input.click();
                }
            });

            // Make entire wrapper clickable - simplified approach
            $wrapper.on('click', function (e) {
                e.preventDefault();
                e.stopPropagation();
                $html5Input.focus();
                if ($html5Input[0].showPicker) {
                    $html5Input[0].showPicker();
                } else {
                    $html5Input.click();
                }
            });

            // Make wrapper focusable for keyboard navigation
            $wrapper.attr('tabindex', '0');
            $wrapper.on('keydown', function (e) {
                if (e.key === 'Enter' || e.key === ' ') { // Enter or Space
                    e.preventDefault();
                    $html5Input.focus();
                    if ($html5Input[0].showPicker) {
                        $html5Input[0].showPicker();
                    } else {
                        $html5Input.click();
                    }
                }
            });

            // Store references for easy access
            $html5Input.data('display-input', $displayInput);
            $displayInput.data('html5-input', $html5Input);
            $displayInput.data('date-format', dateFormat);
        });
    };

    // convertHtml5DateToFormat / parseInputDate / formatDateToTarget moved to
    // assets/js/frontend/util.js as part of #841 (split frontend.js — module 1).
    // The util module attaches them to GravityTable.prototype via Object.assign
    // so callsites here continue to use this.convertHtml5DateToFormat etc.
    // unchanged.
    //
    // #1561: orphan initializeModalDatePickers + private convertHtml5DateToFormat
    // copies were removed -- neither had any caller anywhere in the codebase.
    // The prototype-attached version in util.js is what every live consumer uses.

})(window);

/* ===== assets/js/frontend/export.js ===== */
/**
 * TableCrafter — frontend/export.js
 *
 * Toolbar export pipeline. Fourteenth slice under #833. Two methods,
 * ~150 lines.
 *
 * Public surface (attached directly to GravityTable.prototype):
 *
 *   - initToolbarExport() — wire the export dropdown click handlers
 *     (Copy / CSV / Excel / PDF). Pairs with the pure formatters in
 *     toolbar-export.js (#832 slice 13).
 *   - exportTable(format)  — orchestrate the actual export: fetch all
 *     entries, build the payload via the pure formatters, trigger
 *     download (Blob + anchor + revoke).
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    GravityTable.prototype.initToolbarExport = function () {
        var self = this;
        var $wrapper = $('#' + this.wrapperId);

        $wrapper.on('click', '.gt-toolbar-copy-btn', function (e) {
            e.preventDefault();
            self.toolbarCopyToClipboard();
        });

        $wrapper.on('click', '.gt-toolbar-csv-btn', function (e) {
            e.preventDefault();
            self.toolbarDownloadCSV();
        });

        $wrapper.on('click', '.gt-toolbar-excel-btn', function (e) {
            e.preventDefault();
            self.toolbarDownloadExcel();
        });

        $wrapper.on('click', '.gt-pdf-btn', function (e) {
            e.preventDefault();
            self.toolbarDownloadPDF();
        });

        // #2285 — visible-rows JSON export button.
        $wrapper.on('click', '.gt-toolbar-json-btn', function (e) {
            e.preventDefault();
            self.toolbarDownloadJSON();
        });
    };

    // Toolbar export helpers moved to assets/js/frontend/toolbar-export.js
    // (#832 slice 13 + getVisibleTableData moved in slice 17).

    // Column order localStorage persistence (_columnOrderKey,
    // readStoredColumnOrder, saveStoredColumnOrder, clearStoredColumnOrder)
    // moved to assets/js/frontend/column-order-persistence.js (#832 slice 15).
    //
    // Column reorder DnD UI (getReorderableHeaders, fieldIdFromTh,
    // applyStoredColumnOrderToHead, applyStoredColumnOrderToBody,
    // bindColumnDragEvents, reorderColumn, initColumnReorder,
    // updateColumnReorderResetButton) moved to
    // assets/js/frontend/column-reorder-dnd.js (#832 slice 19).

    GravityTable.prototype.exportTable = function (format) {
        var self = this;
        var $wrapper = $('#' + this.wrapperId);

        // Show loading state
        var $exportBtn = $wrapper.find('.gt-export-btn');
        var originalText = $exportBtn.html();
        $exportBtn.html('<span class="gt-export-icon">⏳</span> Exporting...');
        $exportBtn.prop('disabled', true);

        // Collect current filters (same logic as applyFilters)
        var filters = {};
        var searchTerm = $wrapper.find('.gt-search-input').val() || '';
        var sortField = this.sortField || 'date_created';
        var sortOrder = this.sortOrder || 'desc';

        // Get all current filters
        $wrapper.find('.gt-filter-field').each(function () {
            var $field = $(this);
            var fieldId = $field.data('field-id');
            var fieldType = $field.data('field-type');

            // Process filters same as applyFilters method
            switch (fieldType) {
                case 'date':
                    var $singleHtml5 = $field.find('.gt-date-html5:not(.gt-date-from-html5):not(.gt-date-to-html5)');
                    var $fromHtml5 = $field.find('.gt-date-from-html5');
                    var $toHtml5 = $field.find('.gt-date-to-html5');

                    var fromValue = '';
                    var toValue = '';
                    var dateFormat = (self.config && self.config.date_format) ? self.config.date_format : 'm/d/Y';

                    if ($singleHtml5.length > 0 && $singleHtml5.val()) {
                        fromValue = self.convertHtml5DateToFormat($singleHtml5.val(), dateFormat);
                        toValue = fromValue;
                    } else {
                        if ($fromHtml5.val()) {
                            fromValue = self.convertHtml5DateToFormat($fromHtml5.val(), dateFormat);
                        }
                        if ($toHtml5.val()) {
                            toValue = self.convertHtml5DateToFormat($toHtml5.val(), dateFormat);
                        }
                    }

                    if (fromValue || toValue) {
                        filters['filter_' + fieldId] = {
                            type: 'date_range',
                            from: fromValue || '',
                            to: toValue || ''
                        };
                    }
                    break;

                default:
                    var filterValue = $field.find('input, select').val();
                    if (filterValue && filterValue.length > 0) {
                        filters['filter_' + fieldId] = {
                            type: 'text',
                            value: filterValue
                        };
                    }
                    break;
            }
        });

        // Prepare export data
        var exportData = {
            action: 'gt_export_table',
            nonce: this.config.nonce,
            table_id: this.config.table_id || 0,
            form_id: this.config.form_id,
            format: format,
            search: searchTerm,
            sort_field: sortField,
            sort_order: sortOrder,
            filters: filters,
            columns: this.config.columns
        };

        // Create form and submit for download
        var $form = $('<form>', {
            method: 'POST',
            action: this.config.ajax_url,
            style: 'display: none;'
        });

        // Add all data as hidden inputs
        $.each(exportData, function (key, value) {
            if (typeof value === 'object') {
                $form.append($('<input>', {
                    type: 'hidden',
                    name: key,
                    value: JSON.stringify(value)
                }));
            } else {
                $form.append($('<input>', {
                    type: 'hidden',
                    name: key,
                    value: value
                }));
            }
        });

        $('body').append($form);
        $form.submit();
        $form.remove();

        // Reset button state
        setTimeout(function () {
            $exportBtn.html(originalText);
            $exportBtn.prop('disabled', false);
        }, 1000);
    };


})(window);

/* ===== assets/js/frontend/row-edit.js ===== */
/**
 * TableCrafter — frontend/row-edit.js
 *
 * Full-row edit feature. Fifteenth slice under #833. Two methods,
 * ~126 lines.
 *
 * Public surface (attached directly to GravityTable.prototype):
 *
 *   - editEntireRow(entryId) — expand a row into "edit-all-cells"
 *     mode: render every editable cell as an inline input at once.
 *   - saveAllFields(entryId) — batch-save AJAX: collect every cell
 *     value in the row, POST as a single gt_update_entry call.
 */
(function (window) {
    'use strict';

    /* c8 ignore next 4 */
    if (typeof window.GravityTable !== 'function') {
        // Stub constructor; frontend.js preserves prototype on replace.
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    GravityTable.prototype.editEntireRow = function (entryId) {
        var self = this;
        var $wrapper = $('#' + this.wrapperId);
        var $row = $wrapper.find('tr[data-entry-id="' + entryId + '"]');

        // Check if frontend editing is enabled
        if (!this.config.enable_frontend_editing) {
            alert('Frontend editing is not enabled for this table.');
            return;
        }

        // Get all editable fields in this row
        var $editableFields = $row.find('.gt-editable-field');

        if ($editableFields.length === 0) {
            alert('No editable fields found in this row.');
            return;
        }

        // Put all editable fields into edit mode
        $editableFields.each(function () {
            var $field = $(this);
            // Only edit if not already in edit mode
            if (!$field.find('.gt-edit-input').length) {
                self.editField($field);
            }
        });

        // Add a "Save All" button to the row
        if (!$row.find('.gt-save-all-btn').length) {
            var $actionsCell = $row.find('.gt-actions-cell');
            if ($actionsCell.length) {
                $actionsCell.prepend('<button type="button" class="gt-save-all-btn" data-entry-id="' + entryId + '">Save All</button> ');
            }

            // Bind save all event
            $wrapper.find('.gt-save-all-btn[data-entry-id="' + entryId + '"]').on('click', function () {
                self.saveAllFields(entryId);
            });
        }
    };

    GravityTable.prototype.saveAllFields = function (entryId) {
        var self = this;
        var $wrapper = $('#' + this.wrapperId);
        var $row = $wrapper.find('tr[data-entry-id="' + entryId + '"]');
        var fieldsToSave = {};
        var hasChanges = false;

        // Collect all field values that are in edit mode
        $row.find('.gt-editable-field').each(function () {
            var $field = $(this);
            var $input = $field.find('.gt-edit-input');

            if ($input.length) {
                var fieldId = $field.data('field-id');
                var newValue = $input.val();
                var originalValue = $field.data('original-value') || '';

                if (newValue !== originalValue) {
                    fieldsToSave[fieldId] = newValue;
                    hasChanges = true;
                }
            }
        });

        if (!hasChanges) {
            alert('No changes to save.');
            return;
        }

        // Save all fields via AJAX
        //console.log('GT Frontend: Saving all fields:', fieldsToSave);
        var bulkData = {
            action: 'gt_update_entry_fields',
            nonce: this.config.nonce,
            entry_id: entryId,
            fields: fieldsToSave
        };
        // #553 slice 3 — WAF-safe payload encode for the bulk-edit path.
        // Mirrors the per-field encode above; same gt_waf_safe_payload_enabled
        // filter gate; same opaque base64 envelope under `payload`.
        if (this.config && this.config.waf_safe_payload) {
            var bulkEncoded = btoa(JSON.stringify({
                entry_id: bulkData.entry_id,
                fields: bulkData.fields
            }));
            bulkData = {
                action: 'gt_update_entry_fields',
                nonce: this.config.nonce,
                payload: bulkEncoded
            };
        }
        $.ajax({
            url: this.config.ajax_url,
            type: 'POST',
            data: bulkData,
            success: function (response) {
                //console.log('GT Frontend: Save all fields response:', response);
                if (response.success) {
                    // Update all field displays
                    Object.keys(fieldsToSave).forEach(function (fieldId) {
                        var $field = $row.find('.gt-editable-field[data-field-id="' + fieldId + '"]');
                        $field.html(self.escapeHtml(fieldsToSave[fieldId]));
                    });

                    // Remove save all button
                    $row.find('.gt-save-all-btn').remove();

                    // Reapply conditional formatting to updated cells
                    self.applyConditionalFormatting();

                    alert('All changes saved successfully!');
                    //console.log('GT Frontend: All fields saved successfully');
                } else {
                    console.error('GT Frontend: Save all fields error:', response.data);
                    alert('Error saving changes: ' + (response.data || 'Unknown error'));
                }
            },
            error: function (xhr, status, error) {
                console.error('GT Frontend: Save all fields AJAX failed:', xhr, status, error);
                console.error('GT Frontend: Response text:', xhr.responseText);
                alert('Error saving changes: ' + error);
            }
        });
    };

})(window);

/* ===== assets/js/frontend/edit-indicators.js ===== */
/**
 * TableCrafter — frontend/edit-indicators.js
 *
 * Edit-mode UX overlays. Sixteenth slice under #833. Two methods,
 * ~94 lines.
 *
 *   - showEditIndicator($row, entryId) — show the "editing row N" badge
 *     overlay; auto-hide on click-out.
 *   - showReadonlyIndicator($cell) — flash the readonly-cell tooltip
 *     when the user clicks a non-editable cell.
 */
(function (window) {
    'use strict';

    if (typeof window.GravityTable !== 'function') {
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    GravityTable.prototype.showEditIndicator = function ($row, entryId) {
        var self = this;

        // Don't show edit indicator if frontend editing is disabled
        if (!this.config.enable_frontend_editing) {
            //console.log('GT Frontend: Frontend editing is disabled, not showing edit indicator');
            return;
        }

        // Create edit indicator popup
        var $indicator = $('<div class="gt-edit-indicator">' +
            '<div class="gt-edit-indicator-content">' +
            '<span class="gt-edit-indicator-text">This field is read-only and cannot be edited</span>' +
            '<button type="button" class="gt-edit-row-btn" data-entry-id="' + entryId + '">Edit Row</button>' +
            '</div>' +
            '</div>');

        // Position the indicator relative to the row
        var rowOffset = $row.offset();
        var tableOffset = $('#' + this.wrapperId).offset();

        $indicator.css({
            position: 'absolute',
            top: (rowOffset.top - tableOffset.top + $row.outerHeight() + 5) + 'px',
            left: (rowOffset.left - tableOffset.left + 10) + 'px',
            zIndex: 1000
        });

        // Add to table wrapper
        $('#' + this.wrapperId).append($indicator);

        // Bind click event for edit button
        $indicator.find('.gt-edit-row-btn').on('click', function () {
            // Check if frontend editing is enabled
            if (!self.config.enable_frontend_editing) {
                //console.log('GT Frontend: Frontend editing is disabled');
                $indicator.remove();
                $row.removeClass('gt-row-selected');
                return;
            }

            var entryId = $(this).data('entry-id');
            self.editEntireRow(entryId);
            $indicator.remove();
            $row.removeClass('gt-row-selected');
        });

        // Auto-hide after 5 seconds
        setTimeout(function () {
            if ($indicator.is(':visible')) {
                $indicator.fadeOut(300, function () {
                    $indicator.remove();
                    $row.removeClass('gt-row-selected');
                });
            }
        }, 5000);
    };

    GravityTable.prototype.showReadonlyIndicator = function ($cell) {
        var self = this;

        // Remove any existing indicators
        $('#' + this.wrapperId).find('.gt-readonly-indicator').remove();

        // Create readonly indicator popup
        var $indicator = $('<div class="gt-readonly-indicator">' +
            '<div class="gt-readonly-indicator-content">' +
            '<span class="gt-readonly-indicator-text">This field is read-only and cannot be edited</span>' +
            '</div>' +
            '</div>');

        // Position the indicator relative to the cell
        var cellOffset = $cell.offset();
        var tableOffset = $('#' + this.wrapperId).offset();

        $indicator.css({
            position: 'absolute',
            top: (cellOffset.top - tableOffset.top - 35) + 'px',
            left: (cellOffset.left - tableOffset.left + ($cell.outerWidth() / 2) - 100) + 'px',
            zIndex: 1000
        });

        // Add to table wrapper
        $('#' + this.wrapperId).append($indicator);

        // Auto-hide after 3 seconds
        setTimeout(function () {
            if ($indicator.is(':visible')) {
                $indicator.fadeOut(300, function () {
                    $indicator.remove();
                });
            }
        }, 3000);
    };

})(window);

/* ===== assets/js/frontend/column-resizing.js ===== */
/**
 * TableCrafter — frontend/column-resizing.js
 *
 * Column drag-to-resize feature. Seventeenth slice under #833. One
 * method, ~77 lines.
 *
 *   - initializeColumnResizing($wrapper) — wire mousedown handles on
 *     .gt-resizer elements, drag to resize columns, persist widths
 *     to localStorage per table_id.
 */
(function (window) {
    'use strict';

    if (typeof window.GravityTable !== 'function') {
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    GravityTable.prototype.initializeColumnResizing = function ($wrapper) {
        var self = this;
        var startX, startWidth;
        var $activeTh = null;
        var isResizing = false;

        $wrapper.on('mousedown', '.gt-resizer', function (e) {
            e.preventDefault();
            e.stopPropagation();

            isResizing = true;
            $activeTh = $(this).closest('th');
            startX = e.pageX;
            startWidth = $activeTh.outerWidth();

            $activeTh.addClass('resizing');
            $wrapper.addClass('resizing-active');
            $('body').addClass('gt-resizing-active');

            $(document).on('mousemove.gt-resize', function (e) {
                if (!isResizing) return;

                var width = startWidth + (e.pageX - startX);
                if (width > 50) { // Minimum width
                    $activeTh.css({
                        'width': width + 'px',
                        'min-width': width + 'px',
                        'max-width': width + 'px'
                    });
                }
            });

            $(document).on('mouseup.gt-resize', function () {
                isResizing = false;
                if ($activeTh) {
                    $activeTh.removeClass('resizing');

                    // Persist column width to localStorage so resize works for all users
                    var fieldId = $activeTh.attr('data-field-id');
                    var newWidth = $activeTh.outerWidth();
                    if (fieldId && newWidth && window.localStorage) {
                        var storageKey = 'gt-col-width-' + self.config.table_id + '-' + fieldId;
                        try { localStorage.setItem(storageKey, newWidth); } catch (e) {}
                    }
                }
                $wrapper.removeClass('resizing-active');
                $('body').removeClass('gt-resizing-active');
                $(document).off('mousemove.gt-resize');
                $(document).off('mouseup.gt-resize');
                // Sticky-header layout recalc is no longer needed — native
                // position:sticky on <th> handles re-alignment automatically.
            });
        });

        // Prevent click from bubbling up to sort header when clicking resizer
        $wrapper.on('click', '.gt-resizer', function (e) {
            e.stopPropagation();
            e.preventDefault();
        });

        // Restore saved column widths from localStorage (#111 — works for all users)
        if (window.localStorage && self.config.table_id) {
            $wrapper.find('thead th[data-field-id]').each(function () {
                var fid = $(this).attr('data-field-id');
                var storageKey = 'gt-col-width-' + self.config.table_id + '-' + fid;
                try {
                    var saved = localStorage.getItem(storageKey);
                    if (saved) {
                        var w = parseInt(saved, 10);
                        if (w > 50) {
                            $(this).css({ width: w + 'px', 'min-width': w + 'px', 'max-width': w + 'px' });
                        }
                    }
                } catch (e) {}
            });
        }
    };

})(window);

/* ===== assets/js/frontend/delete-entry.js ===== */
/**
 * TableCrafter — frontend/delete-entry.js
 *
 * Single-entry delete flow. Eighteenth slice under #833. One method,
 * ~53 lines.
 *
 *   - deleteEntry(entryId, $row) — confirm dialog + AJAX delete +
 *     row removal + success/error message.
 */
(function (window) {
    'use strict';

    if (typeof window.GravityTable !== 'function') {
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    GravityTable.prototype.deleteEntry = function (entryId, $row) {
        var self = this;

        // Show loading state
        if ($row) {
            $row.addClass('gt-deleting');
            $row.find('.gt-delete-action').text('⏳').attr('title', 'Deleting...');
        }

        var data = {
            action: 'gt_delete_entry',
            nonce: this.config.nonce,
            entry_id: entryId,
            table_id: this.config.table_id || 0
        };

        $.post(this.config.ajax_url, data, function (response) {
            if (response.success) {
                // Remove row from table with animation
                if ($row) {
                    $row.fadeOut(300, function () {
                        $(this).remove();
                        // Update entry count if visible
                        self.updateEntryCount(-1);
                    });
                } else {
                    // Fallback: find row by entry ID
                    var $targetRow = $('#' + self.wrapperId).find('tr[data-entry-id="' + entryId + '"]');
                    $targetRow.fadeOut(300, function () {
                        $(this).remove();
                        self.updateEntryCount(-1);
                    });
                }

                // Show success message (less intrusive than alert)
                self.showMessage(response.data.message, 'success');
            } else {
                // Reset loading state on error
                if ($row) {
                    $row.removeClass('gt-deleting');
                    $row.find('.gt-delete-action').text('🗑').attr('title', 'Delete');
                }
                self.showMessage('Error deleting entry: ' + response.data, 'error');
            }
        }).fail(function () {
            // Reset loading state on failure
            if ($row) {
                $row.removeClass('gt-deleting');
                $row.find('.gt-delete-action').text('🗑').attr('title', 'Delete');
            }
            self.showMessage('Error deleting entry: Network error', 'error');
        });
    };

})(window);

/* ===== assets/js/frontend/table-utils.js ===== */
/**
 * TableCrafter — frontend/table-utils.js
 *
 * Small table-utility methods. Nineteenth slice under #833. Four
 * methods (~50 lines):
 *
 *   - destroy()          — clean up event handlers and remove the table.
 *   - adjustColumns()    — DataTables-style column-width recompute.
 *   - showMessage(msg, t)— flash a transient inline status message.
 *   - updateEntryCount(delta) — increment/decrement the visible entry count.
 */
(function (window) {
    'use strict';

    if (typeof window.GravityTable !== 'function') {
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    GravityTable.prototype.destroy = function () {
        var $wrapper = $('#' + this.wrapperId);
        $wrapper.off('.gt-table');
        $(document).off('click.gtTable' + this.wrapperId);
        $(document).off('keydown.gtUndo' + this.wrapperId);
        $wrapper.data('gt-initialized', false);
    };

    GravityTable.prototype.adjustColumns = function () {
        // Re-trigger an entry load so DataTables / column widths recalculate
        // now that the wrapper has non-zero dimensions.
        this.loadEntries();
    };

    GravityTable.prototype.showMessage = function (message, type) {
        var $wrapper = $('#' + this.wrapperId);
        var messageClass = type === 'success' ? 'gt-message-success' : 'gt-message-error';

        // Remove any existing messages
        $wrapper.find('.gt-message').remove();

        // Create and show new message
        var $message = $('<div class="gt-message ' + messageClass + '">' + message + '</div>');
        $wrapper.prepend($message);

        // Auto-hide after 5 seconds
        setTimeout(function () {
            $message.fadeOut(300, function () {
                $(this).remove();
            });
        }, 5000);
    };

    GravityTable.prototype.updateEntryCount = function (delta) {
        var $wrapper = $('#' + this.wrapperId);
        var $countElement = $wrapper.find('.gt-entry-count');

        if ($countElement.length > 0) {
            var currentCount = parseInt($countElement.text()) || 0;
            var newCount = Math.max(0, currentCount + delta);
            $countElement.text(newCount);
        }
    };


})(window);

/* ===== assets/js/frontend/url-state.js ===== */
/**
 * TableCrafter — frontend/url-state.js
 *
 * URL drilldown state sync. Twentieth slice under #833. Two methods,
 * ~46 lines.
 *
 *   - updateDrilldownUrlState() — serialize this.drilldownFilters to
 *     ?gt_df=col:val,col:val via history.pushState.
 *   - applyUrlFilters() — parse the inverse on init: read the
 *     drilldownFilters config block + DOM data-attrs and seed
 *     this.filters.
 */
(function (window) {
    'use strict';

    if (typeof window.GravityTable !== 'function') {
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    GravityTable.prototype.updateDrilldownUrlState = function () {
        if (typeof history.pushState === 'undefined') return;
        var filters = this.drilldownFilters || [];
        var parts = [];
        for (var i = 0; i < filters.length; i++) {
            var f = filters[i];
            if (f && f.col) {
                // colon-separated, comma-joined. encodeURIComponent for the value.
                parts.push(f.col + ':' + encodeURIComponent(f.value || ''));
            }
        }
        var qs = parts.join(',');
        var url = new URL(window.location.href);
        if (qs === '') {
            url.searchParams.delete('gt_df');
        } else {
            url.searchParams.set('gt_df', qs);
        }
        history.pushState(null, '', url.toString());
    };

    // TC_URL_Filter_Service — pre-populate per-column filter inputs and
    // seed this.filters from URL params. config.url_filters arrives as a
    // flat { column_id => sanitized_value } map (server-side parsed via
    // TC_URL_Filter_Service::parse_filters). Empty / disabled = no-op.
    GravityTable.prototype.applyUrlFilters = function () {
        if (!this.config || !this.config.url_filters) return;
        var $wrapper = $('#' + this.wrapperId);
        var urlFilters = this.config.url_filters;
        if (!urlFilters || typeof urlFilters !== 'object') return;
        for (var colId in urlFilters) {
            if (!Object.prototype.hasOwnProperty.call(urlFilters, colId)) continue;
            var val = urlFilters[colId];
            if (val == null || val === '') continue;
            // Pre-populate the visible filter input so the customer sees
            // what's filtered. id="gt-filter-{field_id}" — same selector
            // template uses (text + lookup + select all share this id).
            var $input = $wrapper.find('#gt-filter-' + colId);
            if ($input.length) {
                $input.val(val);
            }
            // Seed this.filters in the shape the AJAX backend strips of
            // its 'filter_' prefix at class-tc-ajax.php (text-filter case).
            this.filters['filter_' + colId] = { type: 'text', value: val };
        }
    };

})(window);

/* ===== assets/js/frontend/load-entries.js ===== */
/**
 * TableCrafter — frontend/load-entries.js
 *
 * AJAX entry-fetch path. Twenty-first slice under #833. One method,
 * ~72 lines.
 *
 *   - loadEntries(onComplete) — dispatch to loadEntriesServerSide
 *     when server-side processing is on, otherwise POST gt_get_entries
 *     with current filters/sort/page state and fan out into
 *     renderEntries on success.
 */
(function (window) {
    'use strict';

    if (typeof window.GravityTable !== 'function') {
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    GravityTable.prototype.loadEntries = function (onComplete) {
        if (this.config.processing_mode === 'server') {
            return this.loadEntriesServerSide(onComplete);
        }
        if (this.config.data_source_type === 'woocommerce_products') {
            return this.loadWooCommerceProducts(onComplete);
        }
        //console.log('Loading entries with sort:', this.sortField, this.sortOrder);
        var self = this;
        var $wrapper = $('#' + this.wrapperId);
        var $tbody = $wrapper.find('tbody');

        // Show loading — add wrapper class so CSS can block premature pagination clicks
        $wrapper.addClass('gt-table-loading');
        // #1713 — render a layout-stable shimmer skeleton instead of a single
        // full-width "Loading…" colspan row (which collapsed the columns on
        // sort and read as a flash). Pins the header widths, paints multi-row
        // skeleton rows mirroring the real column/row count.
        self.showLoadingSkeleton($wrapper);

        // #2278 Phase 1 — grammar query detection.
        // When the search term contains grammar operators (field:, -, OR, ""),
        // suppress the server-side LIKE filter (which would match the raw
        // operator string literally) and apply grammar evaluation client-side
        // after the server returns the full row set for the page.
        // Plain queries are not affected: grammarQuery stays '' and the
        // existing server LIKE path runs unchanged.
        var grammarQuery = '';
        var serverSearch = this.searchTerm;
        if (typeof self._hasGrammarOperators === 'function'
                && self._hasGrammarOperators(self.searchTerm)) {
            grammarQuery = self.searchTerm;
            serverSearch = ''; // let server return all rows unfiltered
        }

        // Prepare data
        var data = {
            action: 'gt_get_entries',
            nonce: this.config.nonce,
            form_id: this.config.form_id,
            table_id: this.config.table_id,
            page: this.currentPage,
            per_page: this.config.per_page,
            search: serverSearch,
            sort_field: this.sortField,
            sort_order: this.sortOrder,
            // #565 — pass the full multi-sort stack as JSON. Server validates
            // via TC_Multi_Sort_Service::validate_sort_stack and wires it
            // into the SQL ORDER BY clause when length > 1.
            sort_stack: JSON.stringify(this.sortStack || []),
            // #568 slice 4 — pass drilldown filters (chips) for server-side filtering.
            drilldown_filters: JSON.stringify(this.drilldownFilters || []),
            columns: this.config.columns,
            lookup_fields: this.config.lookup_fields || {}
        };

        // Add preview settings if in preview mode
        if (this.config.is_preview && this.config.preview_settings) {
            data.is_preview = true;
            data.preview_settings = this.config.preview_settings;
        }

        // #1596 — visitor flipped the pivot toggle to raw: ask the
        // server to skip aggregation for this request.
        if (this.pivotViewRaw) {
            data.pivot_view = 'raw';
        }

        // Add filters
        $.extend(data, this.filters);

        // Enable debugging if URL contains debug parameter
        if (window.location.href.indexOf('gt_debug=1') !== -1) {
            data.debug = 'true';
        }

        // #916 — debug logging removed. Re-enable behind a debug flag if needed:
        // console.log('GT Frontend: Loading entries with filters:', this.filters);
        // console.log('GT Frontend: Full AJAX data:', data);

        // AJAX request
        $.post(this.config.ajax_url, data, function (response) {
            //console.log('AJAX response:', response);
            $wrapper.removeClass('gt-table-loading');
            self.releaseColumnWidths($wrapper);
            if (response.success) {
                // #2278 Phase 1 — apply grammar filter client-side when active.
                var renderData = response.data;
                if (grammarQuery && typeof self.grammarFilterEntries === 'function'
                        && renderData && Array.isArray(renderData.entries)) {
                    renderData = jQuery.extend({}, renderData);
                    renderData.entries = self.grammarFilterEntries(renderData.entries, grammarQuery);
                }
                self.renderEntries(renderData);
            } else {
                $tbody.html('<tr><td colspan="' + ($wrapper.find('thead th').length) + '"><div class="gt-error">Error loading entries: ' + response.data + '</div></td></tr>');
            }
            if (typeof onComplete === 'function') { try { onComplete(response); } catch (e) {} }
        }).fail(function (xhr, status, error) {
            //console.log('AJAX request failed:', xhr, status, error);
            $wrapper.removeClass('gt-table-loading');
            self.releaseColumnWidths($wrapper);
            $tbody.html('<tr><td colspan="' + ($wrapper.find('thead th').length) + '"><div class="gt-error">Error loading entries</div></td></tr>');
            if (typeof onComplete === 'function') { try { onComplete(null); } catch (e) {} }
        });
    };

    // #1713 — layout-stable loading skeleton.
    //
    // Replaces the old single full-width "Loading…" colspan row (which let
    // the columns collapse to min-content on every sort/filter/paginate —
    // the "squish" — then snap back when data arrived). Instead we:
    //   1. Pin the current header column widths inline so swapping the
    //      <tbody> can't reflow the header geometry.
    //   2. Paint multi-row shimmer rows that mirror the real column count
    //      and the on-screen row count, so it reads like data loading in
    //      place rather than a flash.
    // Released by releaseColumnWidths() once the real rows render.
    GravityTable.prototype.showLoadingSkeleton = function ($wrapper) {
        var $ths    = $wrapper.find('thead tr').first().find('th');
        var colCount = $ths.length || 1;
        var $tbody  = $wrapper.find('tbody');

        // Mirror the rows currently on screen so the table keeps its height
        // (no collapse / jump). Fall back to per_page on first load, and
        // clamp to a sane band so we never paint hundreds of shimmer rows.
        var existing = $tbody.children('tr')
            .not('.gt-loading-row, .gt-skeleton-row, .gt-no-entries-row, .gt-empty-row, .gt-no-results-row')
            .length;
        var perPage  = parseInt(this.config && this.config.per_page, 10) || 10;
        var rowCount = existing || perPage;
        if (rowCount < 3)  { rowCount = 3; }
        if (rowCount > 15) { rowCount = 15; }

        // Pin the rendered header widths so the body swap can't squish them.
        // jsdom reports 0 here (no layout); real browsers report px widths.
        // Use the .each element arg (not `this`) so the call reads off the DOM
        // node, never the GravityTable instance.
        this._gtPinnedThWidths = true;
        $ths.each(function (i, th) {
            var w = th.getBoundingClientRect ? th.getBoundingClientRect().width : 0;
            if (w && w > 0) {
                th.style.width    = w + 'px';
                th.style.minWidth = w + 'px';
            }
        });

        var cells = '';
        for (var c = 0; c < colCount; c++) {
            cells += '<td class="gt-skeleton-cell"><span class="gt-skeleton-bar"></span></td>';
        }
        var rowHtml = '<tr class="gt-skeleton-row" aria-hidden="true">' + cells + '</tr>';
        var rows = '';
        for (var r = 0; r < rowCount; r++) {
            rows += rowHtml;
        }
        if ($tbody[0]) {
            $tbody[0].innerHTML = rows;
        } else {
            $tbody.html(rows);
        }
    };

    // #1713 — release the header widths pinned by showLoadingSkeleton so the
    // freshly rendered rows can settle to their natural column widths.
    GravityTable.prototype.releaseColumnWidths = function ($wrapper) {
        if (!this._gtPinnedThWidths) { return; }
        $wrapper.find('thead th').each(function (i, th) {
            th.style.width    = '';
            th.style.minWidth = '';
        });
        this._gtPinnedThWidths = false;
    };

})(window);

/* ===== assets/js/frontend/render-entries.js ===== */
/**
 * TableCrafter — frontend/render-entries.js
 *
 * Central DOM render for entries. Twenty-second slice under #833.
 * One method, ~81 lines.
 *
 *   - renderEntries(data) — wipe tbody, render row HTML via
 *     renderEntryRowHtml (entry-row.js, slice 11), bindEntryEvents
 *     (bind-entry-events.js, slice 11), trigger gt-entries-loaded
 *     custom event for scroll indicators.
 */
(function (window) {
    'use strict';

    if (typeof window.GravityTable !== 'function') {
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    GravityTable.prototype.renderEntries = function (data) {
        var self = this;
        var $wrapper = $('#' + this.wrapperId);
        var $tbody = $wrapper.find('tbody');
        var html = '';

        // #613 phase 2 slice 18 (v4.213.0) — initial baseline population.
        // If the server provides per-entry baselines (data.push_baselines),
        // copy them into self._pushBaselines so subsequent pushes carry
        // the right optimistic-concurrency token. Backward compat: when
        // the server doesn't provide push_baselines, _pushBaselines stays
        // undefined and pushRowToSource omits baseline_lastmod as before.
        if (data && data.push_baselines && typeof data.push_baselines === 'object') {
            self._pushBaselines = self._pushBaselines || {};
            for (var k in data.push_baselines) {
                if (Object.prototype.hasOwnProperty.call(data.push_baselines, k)) {
                    self._pushBaselines[String(k)] = String(data.push_baselines[k]);
                }
            }
        }

        // #1596 — pivot responses render through the pivot layout
        // (group-by + aggregate columns) instead of the raw column
        // set. typeof guards keep harnesses without the pivot module
        // on the old path.
        if (typeof self.isPivotResponse === 'function'
            && typeof self.applyPivotView === 'function'
            && self.isPivotResponse(data)) {
            self.applyPivotView(data, $wrapper);
            if (self.config.show_pagination) {
                self.updatePagination(data);
            }
            $(document).trigger('gt-entries-loaded-' + self.wrapperId);
            return;
        }
        if (typeof self.restoreRawHead === 'function') {
            self.restoreRawHead($wrapper);
        }

        // Data Bars (#1731): compute the page-scoped per-column max from
        // the in-memory entries BEFORE rendering rows, so each cell can
        // scale its fill against it. One O(rows) pass, zero extra DB
        // queries. typeof guard keeps harnesses without the module on the
        // old path. Returns {} for SSP / no-bars / no-entries.
        self._barMax = (typeof self.computeBarMaxes === 'function')
            ? self.computeBarMaxes(data.entries, self.config)
            : {};

        // #1733 — server-supplied full-filtered-set maxes override the
        // page-local max so bars scale against ALL matching rows, not
        // just the current page. bar_column_maxes is only present when
        // the Pro gate and column_data_bars are both active.
        if (data && data.bar_column_maxes && typeof data.bar_column_maxes === 'object') {
            for (var barFid in data.bar_column_maxes) {
                if (Object.prototype.hasOwnProperty.call(data.bar_column_maxes, barFid)) {
                    self._barMax[barFid] = data.bar_column_maxes[barFid];
                }
            }
        }

        if (data.entries && data.entries.length > 0) {
            // Per-row HTML builder moved to assets/js/frontend/entry-row.js (#832 slice 11).
            // DOM probes resolved once here so the helper stays jQuery-free.
            var ctx = {
                rowLinkTpl: (self.config && self.config.row_link_template) ? String(self.config.row_link_template) : '',
                hasSelectionHeader: $wrapper.find('.gt-selection-header').length > 0,
                hasDetailHeader: $wrapper.find('.gt-detail-toggle-header').length > 0,
                hasActionsHeader: $wrapper.find('.gt-actions-header').length > 0,
                detailColCount: $wrapper.find('thead tr').first().find('th').length || $wrapper.find('thead th').length,
            };
            // #1049 Option 2 v4.222.0 — hot-loop perf refactor. Behavior
            // pinned by 4 e2e vitest tests; equivalence verified pre/post.
            // 1. $.each -> native for-loop (saves callback indirection per
            //    entry; on a 1000-row table that's 1000 fewer Function
            //    invocations).
            // 2. String concat with html += -> array.push + join. Array.join
            //    is faster than repeated string concat in V8 when the
            //    string list grows past ~50 items.
            var parts = [];
            for (var i = 0, n = data.entries.length; i < n; i++) {
                parts.push(self.renderEntryRowHtml(data.entries[i], ctx));
            }
            html = parts.join('');
        } else {
            var colCount = $wrapper.find('thead th').length;
            var noResultsLabel = (self.config && self.config.pagination_labels && self.config.pagination_labels.no_results) || 'No entries found';
            html += self.renderNoEntriesRowHtml(colCount, noResultsLabel);
        }

        // #1049 Option 2 v4.222.0 — $tbody.html(html) -> direct
        // .innerHTML assignment. jQuery's .html() does extra
        // bookkeeping (script execution, event cleanup) that we
        // don't need here — innerHTML is a single DOM-parse pass.
        if ($tbody[0]) {
            $tbody[0].innerHTML = html;
        } else {
            $tbody.html(html);
        }

        // Re-apply user-customized column order to freshly rendered rows
        if (typeof self.applyStoredColumnOrderToBody === 'function') {
            self.applyStoredColumnOrderToBody();
        }

        // Update pagination
        if (self.config.show_pagination) {
            self.updatePagination(data);
        }

        // Bind events for new elements
        self.bindEntryEvents();

        // Apply conditional formatting to the rendered entries
        self.applyConditionalFormatting();

        // #501 slice 1: row-expiry gate. Hide / strike / sink-to-bottom
        // rows whose date in expiry_field_id is past (or future, in
        // inverse mode). Runs BEFORE auto-merge so merger sees the
        // expiry-filtered DOM.
        self.applyRowExpiry();

        // #518 slice 1: post-render rowspan auto-merge.
        // Walks each column flagged column_auto_merge[field_id]=true and
        // collapses runs of consecutive duplicate cells into a single
        // rowspan=N cell. Runs as the LAST step so it sees the final
        // post-conditional-formatting DOM and any sort / filter / search
        // re-render automatically re-evaluates the merges.
        self.applyAutoMerge();

        // Update cards if in responsive mode
        if (this.config.responsive_mode === 'enhanced') {
            this.updateCardsAfterDataChange($wrapper);
        }

        // Update column totals if enabled
        if (this.config.show_column_totals) {
            this.updateColumnTotals();
        }

        // #1612 — DOM text-node highlight pass: HTML cells, card view,
        // and per-column text-filter terms. Runs LAST so it sees the
        // final DOM (post-merge, post-card-update). typeof guard keeps
        // harnesses without the module on the old path.
        if (typeof self.applyDomSearchHighlight === 'function') {
            self.applyDomSearchHighlight($wrapper);
        }

        // Reset vertical scroll position to the top of the new page (#324)
        var $tableContent = $wrapper.find('.gt-table-content');
        if ($tableContent.length) {
            $tableContent.scrollTop(0);
        }

        // Trigger custom event for scroll indicators
        $(document).trigger('gt-entries-loaded-' + self.wrapperId);
    };

})(window);

/* ===== assets/js/frontend/init.js ===== */
/**
 * TableCrafter — frontend/init.js
 *
 * GravityTable instance lifecycle entry point. **Final** slice under
 * #833 (slice 23). One method, ~125 lines.
 *
 *   - init() — full bootstrap: validate wrapperId+config, dedup guard,
 *     destroy stray DataTables instance (#155), set sticky-header class,
 *     dispatch to bindEvents / setupDateInputs / bindEntryEvents /
 *     setupResponsiveCardView / initFlipResponsive / initCollapsibleRows /
 *     initModalRows / initializeColumnResizing / initVisibilityObserver /
 *     initPresets / initPrint / initToolbarExport / setupScrollIndicators /
 *     initColumnReorder / initUndoRedo / loadEntries.
 *
 * After this slice, frontend.js holds only the bootstrap IIFE: the
 * DOM-ready scanner that finds .gt-table-wrapper elements and the
 * GravityTable constructor that the modules attach onto.
 */
(function (window) {
    'use strict';

    if (typeof window.GravityTable !== 'function') {
        window.GravityTable = function GravityTable() {};
    }

    var $ = window.jQuery;

    GravityTable.prototype.init = function () {
        try {
            // Add error checking for wrapperId and config
            if (!this.wrapperId) {
                console.error('GT Frontend: No wrapper ID provided');
                return;
            }

            if (!this.config) {
                console.error('GT Frontend: No config provided for table:', this.wrapperId);
                return;
            }

            if (window.GTCore && window.GTCore.checkVersionMismatch) {
                // #960 v4.155.1 — was a bare reference to a var inside core.js's IIFE,
                // undefined here in init.js's own IIFE; threw ReferenceError and killed
                // the frontend render. Use the namespace export instead.
                window.GTCore.checkVersionMismatch(this.config, window.GTCore.VERSION);
            }

            // Prevent multiple initializations
            var $wrapper = $('#' + this.wrapperId);
            if ($wrapper.data('gt-initialized')) {
                //console.log('GT Frontend: Table already initialized, skipping:', this.wrapperId);
                return;
            }

            if ($wrapper.length === 0) {
                console.error('GT Frontend: Table wrapper not found:', this.wrapperId);
                return;
            }

            // Mark as initialized only after confirming the wrapper exists
            $wrapper.data('gt-initialized', true);

            // Guard against external DataTables having auto-initialized on our container (#155).
            // Some plugins initialize DataTables on all <table> elements; destroy any stray
            // instance so it doesn't conflict with our own rendering.
            var $table = $wrapper.find('.gt-table');
            if ($table.length && typeof $.fn.DataTable !== 'undefined' && $.fn.DataTable.isDataTable($table[0])) {
                $table.DataTable().destroy();
            }

            if (!this.config.enable_frontend_editing) {
                $wrapper.attr('data-editing-disabled', 'true');
            }

            // Apply sticky header class if enabled. The v4.7.71 CSS rewrite
            // makes this purely class-driven — `position: sticky; top: 0` on
            // <th> handles freezing natively, no JS scroll watcher required.
            if (this.config.sticky_header && !$wrapper.hasClass('sticky-header')) {
                $wrapper.addClass('sticky-header');
            }

            // #544 slice 2: the JS scroll-watcher path is gone — CSS
            // `position: sticky; top: 0` on the <th> elements (rule
            // lives in frontend.css since the v4.7.71 rewrite) keeps
            // the thead in the table flow, so the browser handles
            // header alignment without any JS width-syncing — including
            // in the multi-row frozen-header case, since each th stays
            // in its column's flow and inherits the body cell's
            // effective width. No JS to restore here.

            // Apply freeze-first-column class if enabled (#193)
            if (this.config.freeze_first_column && !$wrapper.hasClass('gt-freeze-first-col')) {
                $wrapper.addClass('gt-freeze-first-col');
            }

            // Apply responsive table class if enabled
            if (this.config.responsive_table) {
                $wrapper.addClass('responsive-table');
                //console.log('GT Frontend: Responsive table enabled');
            }

            // Initialize responsive mode functionality
            if (this.config.responsive_mode) {
                //console.log('GT Frontend: Responsive mode configured as:', this.config.responsive_mode);
                this.initializeResponsiveMode($wrapper);
            }

            // Debug: Log date format configuration
            if (this.config.date_format) {
                //console.log('GT Frontend: Date format configured as:', this.config.date_format);
                //console.log('GT Frontend: JavaScript date format:', this.config.date_format_js);
            }

            this.bindEvents();
            this.setupDateInputs();
            // TC_Default_Sort_Service — per-table initial sort override.
            // Override the hardcoded date_created/desc default before the
            // first AJAX so the table arrives sorted as configured. The
            // existing click-to-sort handler will continue to mutate
            // this.sortField / this.sortOrder when the visitor re-sorts.
            if (this.config && this.config.default_sort_column) {
                this.sortField = String(this.config.default_sort_column);
                this.sortOrder = this.config.default_sort_direction === 'desc' ? 'desc' : 'asc';
                this.sortStack = [{ column_id: this.sortField, direction: this.sortOrder }];
            }
            // TC_URL_Filter_Service — apply URL pre-filters before the
            // first AJAX. Server-side parse_filters has already validated +
            // sanitized, so config.url_filters is a safe column_id => value
            // map. We populate the visible filter inputs (so customers see
            // what's filtered) and seed this.filters in the shape the AJAX
            // backend expects (filter_<id> = {type:'text', value:<v>}).
            this.applyUrlFilters();
            // Persistent filter restore (v4.9.12). URL filters above take
            // precedence — restoreFilterStateLocal merges, doesn't replace.
            this.restoreFilterStateLocal();
            this.loadEntries();
            this.setupScrollIndicators();
            this.initializeColumnResizing($wrapper);
            this.initPrint();
            this.initPresets();
            // #1601 — summary line (no-op unless show_table_summary).
            if (typeof this.initTableSummary === 'function') {
                this.initTableSummary();
            }
            this.initColumnReorder();
            this.initUndoRedo();
            this.initVisibilityObserver($wrapper);
            this.initToolbarExport();
            // #1743 — auto-refresh on interval. startAutoRefresh is a no-op when
            // config.auto_refresh_interval is 0 or absent so we call it unconditionally.
            if (typeof this.startAutoRefresh === 'function') {
                this.startAutoRefresh();
            }
            // #1744 — column visibility picker. initColumnPicker is a no-op when
            // config.show_column_picker is falsy.
            if (typeof this.initColumnPicker === 'function') {
                this.initColumnPicker();
            }
            // #1745 — bulk column fill is triggered from bulk-action toolbar, no init needed.
            // #1746 — column-level role visibility (Pro). Apply once after render.
            if (typeof this.applyColumnRoleVisibility === 'function') {
                this.applyColumnRoleVisibility();
            }

        } catch (error) {
            console.error('GT Frontend: Error in init function:', error);
            console.error('GT Frontend: Error details:', {
                message: error.message,
                stack: error.stack,
                wrapperId: this.wrapperId,
                hasConfig: !!this.config
            });
        }
    };

})(window);
