<?php
/**
 * Documentation View
 *
 * @package GravityTables
 */

if (!defined('ABSPATH')) {
    exit;
}
?>

<div class="wrap">
    <h1><?php _e('TableCrafter Documentation', 'tc-data-tables'); ?></h1>

    <div class="gt-docs-container">
        <div class="gt-docs-sidebar">
            <ul class="gt-docs-nav">
                <li><a href="#getting-started"><?php _e('Getting Started', 'tc-data-tables'); ?></a></li>
                <li><a href="#creating-tables"><?php _e('Creating Tables', 'tc-data-tables'); ?></a></li>
                <li><a href="#table-features"><?php _e('Table Features', 'tc-data-tables'); ?></a></li>
                <li><a href="#shortcodes"><?php _e('Using Shortcodes', 'tc-data-tables'); ?></a></li>
                <li><a href="#frontend-editing"><?php _e('Frontend Editing', 'tc-data-tables'); ?></a></li>
                <li><a href="#bulk-actions"><?php _e('Bulk Actions', 'tc-data-tables'); ?></a></li>
                <li><a href="#advanced-filters"><?php _e('Advanced Filters', 'tc-data-tables'); ?></a></li>
                <li><a href="#detail-rows"><?php _e('Detail Rows (Entry Preview)', 'tc-data-tables'); ?></a></li>
                <li><a href="#drilldown"><?php _e('Click-to-Filter Drilldown', 'tc-data-tables'); ?></a></li>
                <li><a href="#row-link"><?php _e('Row Link Click-Through', 'tc-data-tables'); ?></a></li>
                <li><a href="#pivot"><?php _e('Pivot Tables', 'tc-data-tables'); ?></a></li>
                <li><a href="#sorting"><?php _e('Sorting', 'tc-data-tables'); ?></a></li>
                <li><a href="#row-display"><?php _e('Row Display Controls', 'tc-data-tables'); ?></a></li>
                <li><a href="#cell-types"><?php _e('Cell Types', 'tc-data-tables'); ?></a></li>
                <li><a href="#data-management-tools"><?php _e('Find &amp; Replace, Bulk Tools', 'tc-data-tables'); ?></a></li>
                <li><a href="#data-sources"><?php _e('External Data Sources', 'tc-data-tables'); ?></a></li>
                <li><a href="#ai-tools"><?php _e('AI Tools', 'tc-data-tables'); ?></a></li>
                <li><a href="#url-filters"><?php _e('URL-Driven Filters', 'tc-data-tables'); ?></a></li>
                <li><a href="#print-export"><?php _e('Print &amp; Export', 'tc-data-tables'); ?></a></li>
                <li><a href="#conditional-formatting"><?php _e('Conditional Formatting', 'tc-data-tables'); ?></a></li>
                <li><a href="#lookup-fields"><?php _e('Lookup Fields', 'tc-data-tables'); ?></a></li>
                <li><a href="#customization"><?php _e('Customization', 'tc-data-tables'); ?></a></li>
                <li><a href="#datecoder-integration"><?php _e('DateCoder Tool Integration', 'tc-data-tables'); ?></a></li>
                <li><a href="#mobile-responsiveness"><?php _e('Mobile Responsiveness', 'tc-data-tables'); ?></a></li>
                <li><a href="#troubleshooting"><?php _e('Troubleshooting', 'tc-data-tables'); ?></a></li>
                <li><a href="#permission-troubleshooting"><?php _e('Permission Troubleshooting', 'tc-data-tables'); ?></a></li>
                <li><a href="#free-vs-pro"><?php _e('Plans: Free vs Pro', 'tc-data-tables'); ?></a></li>
                <li><a href="#whats-new"><?php printf(esc_html__("What's New (v%s)", 'tc-data-tables'), defined('TC_VERSION') ? TC_VERSION : '4.x'); ?></a></li>
            </ul>
        </div>

        <div class="gt-docs-content">
            <style>
                .gt-tier-badge{display:inline-block;font-size:11px;font-weight:600;line-height:1;padding:3px 8px;border-radius:10px;vertical-align:middle;letter-spacing:.02em}
                .gt-tier-free{background:#e6f4ea;color:#137333}
                .gt-tier-pro{background:#fdecea;color:#b3261e}
                .gt-tier-badge-link{text-decoration:none}
                .gt-tier-table{width:100%;border-collapse:collapse;margin:12px 0}
                .gt-tier-table th,.gt-tier-table td{text-align:left;padding:8px 10px;border-bottom:1px solid #eee}
                .gt-tier-table th{font-weight:600}
                .gt-tier-upsell{margin-top:8px;color:#555}
            </style>
            <div id="free-vs-pro" class="gt-docs-section">
                <h2><?php _e('Plans: Free vs Pro', 'tc-data-tables'); ?></h2>
                <p><?php _e('Everything below works in the free plugin unless marked Pro. The Pro items are where TableCrafter does the heavy lifting — pull this matrix as your upgrade checklist.', 'tc-data-tables'); ?></p>

                <?php $tc_upgrade_url = (function_exists('wgt_fs') && wgt_fs()) ? wgt_fs()->get_upgrade_url() : ''; ?>

                <h3><?php _e('Data sources', 'tc-data-tables'); ?></h3>
                <table class="gt-tier-table">
                    <thead><tr><th><?php _e('Source', 'tc-data-tables'); ?></th><th><?php _e('Plan', 'tc-data-tables'); ?></th></tr></thead>
                    <tbody>
                    <?php
                    if (class_exists('TC_Source_Registry') && function_exists('tc_tier_badge')) {
                        foreach (TC_Source_Registry::all() as $tc_src) {
                            $tc_is_pro = !empty($tc_src['pro']);
                            echo '<tr><td>' . esc_html((string) ($tc_src['label'] ?? '')) . '</td><td>'
                                . tc_tier_badge($tc_is_pro, $tc_is_pro ? $tc_upgrade_url : null) . '</td></tr>';
                        }
                    }
                    ?>
                    </tbody>
                </table>
                <p class="gt-tier-upsell"><?php _e('The free tier covers generic file/URL sources (JSON, CSV, Google Sheets, Excel). The rich integrations — Gravity Forms, WooCommerce, Airtable, Notion, XML, and External DB — are Pro.', 'tc-data-tables'); ?></p>

                <h3><?php _e('Features', 'tc-data-tables'); ?></h3>
                <table class="gt-tier-table">
                    <thead><tr><th><?php _e('Feature', 'tc-data-tables'); ?></th><th><?php _e('Plan', 'tc-data-tables'); ?></th></tr></thead>
                    <tbody>
                        <tr><td><?php _e('Inline shortcode tables (search, sort, paginate, export, auto-refresh)', 'tc-data-tables'); ?></td><td><?php echo tc_tier_badge(false); ?></td></tr>
                        <tr><td><?php _e('Table builder, column config, filters, pagination, permissions', 'tc-data-tables'); ?></td><td><?php echo tc_tier_badge(false); ?></td></tr>
                        <tr><td><?php _e('Gutenberg block + Elementor widget', 'tc-data-tables'); ?></td><td><?php echo tc_tier_badge(false); ?></td></tr>
                        <tr><td><?php _e('Frontend inline editing &amp; bulk operations', 'tc-data-tables'); ?></td><td><?php echo tc_tier_badge(true, $tc_upgrade_url); ?></td></tr>
                        <tr><td><?php _e('Two-way sync / write-back (Airtable, Notion)', 'tc-data-tables'); ?></td><td><?php echo tc_tier_badge(true, $tc_upgrade_url); ?></td></tr>
                        <tr><td><?php _e('External database connections (MySQL / SQL Server)', 'tc-data-tables'); ?></td><td><?php echo tc_tier_badge(true, $tc_upgrade_url); ?></td></tr>
                        <tr><td><?php _e('Scheduled exports, webhooks, revisions / audit log', 'tc-data-tables'); ?></td><td><?php echo tc_tier_badge(true, $tc_upgrade_url); ?></td></tr>
                    </tbody>
                </table>
                <?php if ($tc_upgrade_url) : ?>
                    <p><a class="button button-primary" href="<?php echo esc_url($tc_upgrade_url); ?>"><?php _e('See what Pro unlocks', 'tc-data-tables'); ?></a></p>
                <?php endif; ?>
            </div>

            <div id="getting-started" class="gt-docs-section">
                <h2><?php _e('Getting Started', 'tc-data-tables'); ?></h2>

                <div class="gt-docs-intro-callout">
                    <p><?php _e('TableCrafter transforms your data into powerful, interactive tables. With comprehensive frontend editing, intelligent filtering, bulk operations, and entry creation capabilities, it provides a complete data management solution for your WordPress site.', 'tc-data-tables'); ?></p>
                </div>

                <h3><?php _e('Key Features', 'tc-data-tables'); ?></h3>
                <div class="gt-docs-features-grid">
                    <div class="gt-docs-feature-card">
                        <span class="dashicons dashicons-edit"></span>
                        <div class="gt-docs-feature-card-body">
                            <strong><?php _e('Smart Frontend Editing', 'tc-data-tables'); ?></strong>
                            <p><?php _e('Edit entries directly in the table with Enter/Esc commands.', 'tc-data-tables'); ?></p>
                        </div>
                    </div>
                    <div class="gt-docs-feature-card">
                        <span class="dashicons dashicons-filter"></span>
                        <div class="gt-docs-feature-card-body">
                            <strong><?php _e('Dynamic Filters', 'tc-data-tables'); ?></strong>
                            <p><?php _e('Auto-generated filter options from your actual data, plus URL-driven filters for shareable views.', 'tc-data-tables'); ?></p>
                        </div>
                    </div>
                    <div class="gt-docs-feature-card">
                        <span class="dashicons dashicons-visibility"></span>
                        <div class="gt-docs-feature-card-body">
                            <strong><?php _e('Detail Rows', 'tc-data-tables'); ?></strong>
                            <p><?php _e('Eye-icon entry preview opens a popup with the full Gravity Forms entry detail.', 'tc-data-tables'); ?></p>
                        </div>
                    </div>
                    <div class="gt-docs-feature-card">
                        <span class="dashicons dashicons-search"></span>
                        <div class="gt-docs-feature-card-body">
                            <strong><?php _e('Click-to-Filter Drilldown', 'tc-data-tables'); ?></strong>
                            <p><?php _e('Click any cell to filter the table to that value (per-column opt-in).', 'tc-data-tables'); ?></p>
                        </div>
                    </div>
                    <div class="gt-docs-feature-card">
                        <span class="dashicons dashicons-grid-view"></span>
                        <div class="gt-docs-feature-card-body">
                            <strong><?php _e('Pivot Tables', 'tc-data-tables'); ?></strong>
                            <p><?php _e('Group, aggregate, and reshape entries without writing SQL.', 'tc-data-tables'); ?></p>
                        </div>
                    </div>
                    <div class="gt-docs-feature-card">
                        <span class="dashicons dashicons-external"></span>
                        <div class="gt-docs-feature-card-body">
                            <strong><?php _e('Row Link Click-Through', 'tc-data-tables'); ?></strong>
                            <p><?php _e('Make entire rows clickable, routing to a URL built from entry data.', 'tc-data-tables'); ?></p>
                        </div>
                    </div>
                    <div class="gt-docs-feature-card">
                        <span class="dashicons dashicons-plus-alt2"></span>
                        <div class="gt-docs-feature-card-body">
                            <strong><?php _e('Entry Creation', 'tc-data-tables'); ?></strong>
                            <p><?php _e('Add new entries directly from the frontend.', 'tc-data-tables'); ?></p>
                        </div>
                    </div>
                    <div class="gt-docs-feature-card">
                        <span class="dashicons dashicons-list-view"></span>
                        <div class="gt-docs-feature-card-body">
                            <strong><?php _e('Bulk Operations', 'tc-data-tables'); ?></strong>
                            <p><?php _e('Select multiple entries for bulk delete, export, or edit.', 'tc-data-tables'); ?></p>
                        </div>
                    </div>
                    <div class="gt-docs-feature-card">
                        <span class="dashicons dashicons-cloud"></span>
                        <div class="gt-docs-feature-card-body">
                            <strong><?php _e('External Data Sources', 'tc-data-tables'); ?></strong>
                            <p><?php _e('Pull from JSON endpoints, Airtable, or Notion alongside Gravity Forms entries.', 'tc-data-tables'); ?></p>
                        </div>
                    </div>
                    <div class="gt-docs-feature-card">
                        <span class="dashicons dashicons-superhero"></span>
                        <div class="gt-docs-feature-card-body">
                            <strong><?php _e('AI Tools', 'tc-data-tables'); ?></strong>
                            <p><?php _e('Optional AI cleanup suggestions and table summaries.', 'tc-data-tables'); ?></p>
                        </div>
                    </div>
                    <div class="gt-docs-feature-card">
                        <span class="dashicons dashicons-smartphone"></span>
                        <div class="gt-docs-feature-card-body">
                            <strong><?php _e('Responsive Design', 'tc-data-tables'); ?></strong>
                            <p><?php _e('Tables adapt to any screen size; mobile card view, sticky headers, and quick visibility toggles.', 'tc-data-tables'); ?></p>
                        </div>
                    </div>
                </div>

                <h3><?php _e('Prerequisites', 'tc-data-tables'); ?></h3>
                <ul class="gt-docs-prereqs">
                    <li><span class="dashicons dashicons-yes-alt"></span><?php _e('Gravity Forms plugin must be installed and activated', 'tc-data-tables'); ?></li>
                    <li><span class="dashicons dashicons-yes-alt"></span><?php _e('At least one Gravity Form with entries', 'tc-data-tables'); ?></li>
                    <li><span class="dashicons dashicons-yes-alt"></span><?php _e('WordPress users with appropriate permissions (edit_posts or a configured role)', 'tc-data-tables'); ?></li>
                </ul>
            </div>

            <div id="creating-tables" class="gt-docs-section">
                <h2><?php _e('Creating Tables', 'tc-data-tables'); ?></h2>
                <p><?php _e('To create a new table:', 'tc-data-tables'); ?></p>
                <ol>
                    <li><?php _e('Go to TableCrafter → Add New', 'tc-data-tables'); ?></li>
                    <li><?php _e('Enter a title for your table', 'tc-data-tables'); ?></li>
                    <li><?php _e('Select the Gravity Form whose entries you want to display', 'tc-data-tables'); ?></li>
                    <li><?php _e('Configure the fields you want to display using the tabbed interface', 'tc-data-tables'); ?></li>
                    <li><?php _e('Set up table features like pagination, search, and bulk actions in the General tab', 'tc-data-tables'); ?></li>
                    <li><?php _e('Configure Advanced settings like lookup fields, detail rows, and click-to-filter drilldown', 'tc-data-tables'); ?></li>
                    <li><?php _e('Set up Filtering options with intelligent defaults for each field type', 'tc-data-tables'); ?></li>
                    <li><?php _e('Apply Conditional Formatting rules to highlight important data', 'tc-data-tables'); ?></li>
                    <li><?php _e('Preview and save your table', 'tc-data-tables'); ?></li>
                </ol>

                <h3><?php _e('Tabbed Interface', 'tc-data-tables'); ?></h3>
                <p><?php _e('The table builder uses a tabbed interface:', 'tc-data-tables'); ?></p>
                <ul>
                    <li><strong><?php _e('General', 'tc-data-tables'); ?></strong> - <?php _e('Basic table settings and display options', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Advanced', 'tc-data-tables'); ?></strong> - <?php _e('Lookup fields, detail rows, drilldown, row link, pivot, and other advanced features', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Filtering', 'tc-data-tables'); ?></strong> - <?php _e('Comprehensive filter configuration with intelligent defaults', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Conditional Formatting', 'tc-data-tables'); ?></strong> - <?php _e('Visual styling rules based on field values', 'tc-data-tables'); ?></li>
                </ul>
            </div>

            <div id="table-features" class="gt-docs-section">
                <h2><?php _e('Table Features', 'tc-data-tables'); ?></h2>
                <p><?php _e('TableCrafter provides a comprehensive suite of features for managing your data:', 'tc-data-tables'); ?></p>

                <h3><?php _e('Display &amp; Navigation', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Responsive Design', 'tc-data-tables'); ?></strong> - <?php _e('Tables automatically adapt to screen size with intelligent text wrapping.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Sticky Headers &amp; Sticky Rows', 'tc-data-tables'); ?></strong> - <?php _e('Headers and pinned rows stay visible while scrolling.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Pagination &amp; Custom Labels', 'tc-data-tables'); ?></strong> - <?php _e('Split large datasets across pages, with customizable per-page label text.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Global Search', 'tc-data-tables'); ?></strong> - <?php _e('Search across all visible columns simultaneously, with optional fuzzy matching.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Skeleton Loader', 'tc-data-tables'); ?></strong> - <?php _e('Placeholder UI while data loads, so the table never looks broken on slow connections.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Scroll Buttons', 'tc-data-tables'); ?></strong> - <?php _e('Optional left/right scroll arrows for wide tables on touch devices.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Toolbar Visibility', 'tc-data-tables'); ?></strong> - <?php _e('Per-table control over which toolbar buttons (Add, Export, Print, etc.) are visible.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('Data Management', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Smart Frontend Editing', 'tc-data-tables'); ?></strong> - <?php _e('Click any field to edit with Enter/Esc commands.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Add New Entries', 'tc-data-tables'); ?></strong> - <?php _e('Create new form entries directly from the table interface.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Bulk Operations', 'tc-data-tables'); ?></strong> - <?php _e('Select multiple entries for bulk delete, export, or edit.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Find &amp; Replace', 'tc-data-tables'); ?></strong> - <?php _e('Search and replace values across selected rows or the whole table.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Auto-Save', 'tc-data-tables'); ?></strong> - <?php _e('Changes are saved automatically with visual feedback.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Revision Snapshots', 'tc-data-tables'); ?></strong> - <?php _e('Periodic snapshots of table configurations let you roll back if a save goes wrong.', 'tc-data-tables'); ?></li>
                </ul>
            </div>

            <div id="shortcodes" class="gt-docs-section">
                <h2><?php _e('Using Shortcodes', 'tc-data-tables'); ?></h2>
                <p><?php _e('Tables are displayed using a simple shortcode with your saved table configuration:', 'tc-data-tables'); ?></p>

                <h3><?php _e('Basic Usage', 'tc-data-tables'); ?></h3>
                <p><?php _e('After creating and configuring your table in the admin, use:', 'tc-data-tables'); ?></p>
                <pre>[tablecrafter id="123"]</pre>
                <p><?php _e('Replace "123" with your actual table ID. This method preserves all your configuration, filtering, conditional formatting, and advanced settings.', 'tc-data-tables'); ?></p>

                <h3><?php _e('Finding Your Table ID', 'tc-data-tables'); ?></h3>
                <ol>
                    <li><?php _e('Go to TableCrafter in your WordPress admin', 'tc-data-tables'); ?></li>
                    <li><?php _e('Find your table in the list', 'tc-data-tables'); ?></li>
                    <li><?php _e('The ID number is shown in the first column', 'tc-data-tables'); ?></li>
                    <li><?php _e('Copy the shortcode provided in the "Shortcode" column', 'tc-data-tables'); ?></li>
                </ol>
            </div>

            <div id="frontend-editing" class="gt-docs-section">
                <h2><?php _e('Frontend Editing', 'tc-data-tables'); ?></h2>
                <p><?php _e('Frontend editing allows users to modify entries directly from the table view. This feature can be enabled globally in settings and per-table during creation.', 'tc-data-tables'); ?></p>

                <h3><?php _e('How to Use', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><?php _e('Click any editable field to start editing', 'tc-data-tables'); ?></li>
                    <li><?php _e('Press Enter to save your changes', 'tc-data-tables'); ?></li>
                    <li><?php _e('Press Escape to cancel editing', 'tc-data-tables'); ?></li>
                    <li><?php _e('Lookup fields show a search-as-you-type input. Pick a suggestion to auto-save.', 'tc-data-tables'); ?></li>
                    <li><?php _e('On mobile, changes auto-save when you tap outside the field.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('Sanitization', 'tc-data-tables'); ?></h3>
                <p><?php _e('Inline edits go through a server-side sanitization layer (TC_Inline_Edit_Sanitizer) that strips HTML, neutralizes CSV-formula injection (cells starting with =, +, -, @), and validates against the field type before writing back to the entry.', 'tc-data-tables'); ?></p>

                <h3><?php _e('Security', 'tc-data-tables'); ?></h3>
                <p><?php _e('Frontend editing respects WordPress user roles and capabilities. Only users with appropriate permissions can edit entries.', 'tc-data-tables'); ?></p>
            </div>

            <div id="bulk-actions" class="gt-docs-section">
                <h2><?php _e('Bulk Actions', 'tc-data-tables'); ?></h2>
                <p><?php _e('Pro plan only. Tick the row checkboxes, then pick an action from the toolbar:', 'tc-data-tables'); ?></p>
                <ul>
                    <li><strong><?php _e('Delete', 'tc-data-tables'); ?></strong> - <?php _e('Remove selected entries.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Export', 'tc-data-tables'); ?></strong> - <?php _e('Export selected entries as CSV with a customizable filename pattern.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Edit', 'tc-data-tables'); ?></strong> - <?php _e('Apply the same value to one field across every selected entry. Straight overwrite — no append/prepend modes.', 'tc-data-tables'); ?></li>
                </ul>
                <p><?php _e('These three actions are the complete set registered by the bulk_action AJAX handler.', 'tc-data-tables'); ?></p>
            </div>

            <div id="advanced-filters" class="gt-docs-section">
                <h2><?php _e('Advanced Filters', 'tc-data-tables'); ?></h2>
                <p><?php _e('Filters are always visible by default and dynamically generated from your actual entries.', 'tc-data-tables'); ?></p>

                <h3><?php _e('Filter Types', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Text Filters', 'tc-data-tables'); ?></strong> - <?php _e('Search within field values, with case-sensitive and exact-match options.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Dynamic Dropdown Filters', 'tc-data-tables'); ?></strong> - <?php _e('Auto-populated with unique values from your entries. Toggle "Allow multiple selections" in the column\'s filter config to switch to multi-select.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Date Filters', 'tc-data-tables'); ?></strong> - <?php _e('Range selection with preset buttons (Today, This Week, This Month) plus relative-date filters (Last 7 days, This Quarter, etc.).', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Numeric Range Filters', 'tc-data-tables'); ?></strong> - <?php _e('Min/max input fields with custom step sizes.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Checkbox Filters', 'tc-data-tables'); ?></strong> - <?php _e('Multiple selection with AND/OR logic and Select All.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Combined Filters', 'tc-data-tables'); ?></strong> - <?php _e('Multiple filters apply together with AND semantics. Drilldown filters (see below) layer on top.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('Intelligent Defaults', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><?php _e('Date fields → Date range filters with presets', 'tc-data-tables'); ?></li>
                    <li><?php _e('Number/Phone fields → Range filters with step controls', 'tc-data-tables'); ?></li>
                    <li><?php _e('Select/Radio fields → Dropdown filters with predefined choices', 'tc-data-tables'); ?></li>
                    <li><?php _e('Multi-select/Checkbox fields → Checkbox filters with logic options', 'tc-data-tables'); ?></li>
                    <li><?php _e('Text fields → Smart dropdown filters populated from actual data', 'tc-data-tables'); ?></li>
                    <li><?php _e('AJS Lookup fields → Dropdown auto-populated from the lookup\'s canonical value list', 'tc-data-tables'); ?></li>
                </ul>
            </div>

            <div id="detail-rows" class="gt-docs-section">
                <h2><?php _e('Detail Rows (Entry Preview)', 'tc-data-tables'); ?></h2>
                <p><?php _e('The eye icon next to each row opens a popup showing the full Gravity Forms entry, including fields that aren\'t in the table\'s visible columns. Useful for quickly reviewing an entry without opening the GF entry-detail screen.', 'tc-data-tables'); ?></p>

                <h3><?php _e('Enabling Detail Rows', 'tc-data-tables'); ?></h3>
                <ol>
                    <li><?php _e('Edit the table → Advanced tab', 'tc-data-tables'); ?></li>
                    <li><?php _e('Toggle "Show entry detail (eye icon)" on', 'tc-data-tables'); ?></li>
                    <li><?php _e('Optionally configure which fields appear in the popup (defaults to all form fields)', 'tc-data-tables'); ?></li>
                    <li><?php _e('Save the table', 'tc-data-tables'); ?></li>
                </ol>

                <h3><?php _e('What Users See', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><?php _e('An eye icon appears in each row\'s actions cell', 'tc-data-tables'); ?></li>
                    <li><?php _e('Clicking it opens a modal with the entry\'s field values', 'tc-data-tables'); ?></li>
                    <li><?php _e('A direct link to the Gravity Forms entry-detail screen is included for admins', 'tc-data-tables'); ?></li>
                </ul>
            </div>

            <div id="drilldown" class="gt-docs-section">
                <h2><?php _e('Click-to-Filter Drilldown', 'tc-data-tables'); ?></h2>
                <p><?php _e('TC_Drilldown_Filter_Service (includes/services/class-tc-drilldown-filter-service.php) is a complete drilldown implementation, but no production code wires it into the frontend or table builder today: there is no "Drilldown section" in the Advanced tab, no JS click-to-filter handler in assets/js/frontend.js, and no AJAX endpoint that consumes drilldown state. Same orphan-with-test-contract pattern as the running list (multi-sort v4.7.155/156, bulk-migration-bundle v4.7.158, skeleton v4.7.162, AI tools v4.7.163, external sources v4.7.164, pivot v4.7.165). NOTE: a previous draft of this section pointed at URL-Driven Filters as a working alternative — that turned out to be wrong too (see the URL-Driven Filters section below for the correct status; v4.7.169 unwound that misclaim).', 'tc-data-tables'); ?></p>
            </div>

            <div id="row-link" class="gt-docs-section">
                <h2><?php _e('Row Link Click-Through', 'tc-data-tables'); ?></h2>
                <p><?php _e('Make entire rows clickable, navigating to a URL constructed from the entry\'s field values. Useful for "open the underlying record" workflows.', 'tc-data-tables'); ?></p>

                <h3><?php _e('Configuring', 'tc-data-tables'); ?></h3>
                <ol>
                    <li><?php _e('Edit the table → Advanced tab → Row Link section', 'tc-data-tables'); ?></li>
                    <li><?php _e('Enter a URL template using {field_id} placeholders, e.g. https://example.com/customer/{31}', 'tc-data-tables'); ?></li>
                    <li><?php _e('Choose target (same tab or new tab)', 'tc-data-tables'); ?></li>
                    <li><?php _e('Optionally exclude specific columns (so click-to-edit on those cells still works)', 'tc-data-tables'); ?></li>
                </ol>

                <h3><?php _e('Notes', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><?php _e('Editable cells short-circuit the row link so users can still edit inline', 'tc-data-tables'); ?></li>
                    <li><?php _e('Field placeholder values are URL-encoded automatically', 'tc-data-tables'); ?></li>
                    <li><?php _e('If a placeholder field is empty for a row, the link is suppressed for that row', 'tc-data-tables'); ?></li>
                </ul>
            </div>

            <div id="pivot" class="gt-docs-section">
                <h2><?php _e('Pivot Tables', 'tc-data-tables'); ?></h2>
                <p><?php _e('TC_Pivot_Service (includes/services/class-tc-pivot-service.php) is a complete pivot-table implementation with row/column grouping and the full sum/count/avg/min/max aggregate suite. It ships with a #562 test contract pinning the include. However, no production code wires it into the table builder UI today: there is no "Pivot section" in the Advanced tab, no shortcode parameter that toggles pivot mode, and no AJAX endpoint that returns pivot output. Same orphan-with-test-contract pattern as multi-sort / skeleton / bulk-migration-bundle / AI tools / external sources (see the v4.7.156 / v4.7.158 / v4.7.162 / v4.7.163 / v4.7.164 changelog for the running list). To use the pivot logic today, instantiate TC_Pivot_Service from a custom plugin and feed it your own data array — the public method signatures are documented in the source file. The wire-up to the table builder is roadmap.', 'tc-data-tables'); ?></p>
            </div>

            <div id="sorting" class="gt-docs-section">
                <h2><?php _e('Sorting', 'tc-data-tables'); ?></h2>

                <h3><?php _e('Default Sort', 'tc-data-tables'); ?></h3>
                <p><?php _e('Set a per-table default sort column and direction in Advanced → Default Sort. Applied on every page load before any user-specified sort.', 'tc-data-tables'); ?></p>

                <h3><?php _e('Click-to-Sort', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><?php _e('Click a column header to sort by it ascending; click again for descending; click again to clear.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Sort indicators in the header show the active sort.', 'tc-data-tables'); ?></li>
                </ul>
                <p><?php _e('Multi-column shift-click sort is wired up as of v4.9.0 (slice 1 — UX layer with numbered badges) and v4.9.1 (slice 2 — SQL ORDER BY refactor with per-clause helper). Closes #565. Shift-click on a column header adds it to the sort stack (max 3); plain click resets to single-column. Numbered badges (1, 2, 3) appear next to sort arrows when the stack has 2+ entries. Per-table enable_multi_sort toggle in the table builder Display Options (default ON). Service-validated server-side via TC_Multi_Sort_Service::validate_sort_stack. Both slices closed; secondary tiebreakers apply across the entire result set, not just within the visible page.', 'tc-data-tables'); ?></p>
            </div>

            <div id="row-display" class="gt-docs-section">
                <h2><?php _e('Row Display Controls', 'tc-data-tables'); ?></h2>

                <h3><?php _e('Wrap Mode (per column)', 'tc-data-tables'); ?></h3>
                <p><?php _e('Per-column choice between truncate, wrap, and expand-on-hover. The setting is persisted as column_wrap_modes in the table settings JSON; templates/table.php consumes the value via TC_Wrap_Mode_Service::css_for_mode and emits the corresponding inline style on each header and body cell. This is the only Row-Display feature in the Row Display services directory that is currently wired into the rendering path.', 'tc-data-tables'); ?></p>

                <h3><?php _e('Other row-display services exist but are not wired up yet', 'tc-data-tables'); ?></h3>
                <p><?php _e('TC_Row_Grouping_Service, TC_Sticky_Rows_Service, TC_Row_Height_Service, TC_Cell_Merge_Service, TC_Row_Expiry_Service, TC_Rowspan_Merge_Service, and TC_Collapsible_Service all ship as runnable PHP with their own test contracts, but grep -rEln across templates/, assets/js/, includes/class-tc-shortcode.php, includes/class-tc-ajax.php returns zero non-service-file references for any of them. There is no UI in the table builder, no rendering hookup, no shortcode parameter. Same orphan-with-test-contract pattern as the running list (multi-sort v4.7.155/156, bulk-migration-bundle v4.7.158, skeleton v4.7.162, AI tools v4.7.163, external sources v4.7.164, pivot v4.7.165, drilldown v4.7.166). To use any of these row-display logics today, instantiate the service from a custom plugin and call its public methods directly.', 'tc-data-tables'); ?></p>
            </div>

            <div id="cell-types" class="gt-docs-section">
                <h2><?php _e('Cell Types', 'tc-data-tables'); ?></h2>
                <p><?php _e('Beyond plain text, columns can render as:', 'tc-data-tables'); ?></p>
                <ul>
                    <li><strong><?php _e('Date / Time', 'tc-data-tables'); ?></strong> - <?php _e('Formatted via TC_Date_Service / TC_Date_Format_Service (called from class-tc-ajax.php and class-tc-entry-service.php). See the DateCoder Integration section for the format-string editor.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Lookup Field', 'tc-data-tables'); ?></strong> - <?php _e('User, post, or custom-table lookups render the human-readable name. TC_Lookup is wired through includes/class-tc-lookup.php, includes/class-tc-ajax.php, includes/repositories/class-tc-entry-repository.php, and templates/table.php. See the Lookup Fields section.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Conditional Format', 'tc-data-tables'); ?></strong> - <?php _e('Background/text color, font-weight rules driven by cell value. TC_Conditional_Formatting_Service is consumed by includes/class-tc-shortcode.php and the rule schema is validated by includes/services/class-tc-validation-service.php. See the Conditional Formatting section.', 'tc-data-tables'); ?></li>
                </ul>
                <p><?php _e('Two cell-type services are also in includes/services/ but not currently wired into the rendering path:', 'tc-data-tables'); ?></p>
                <ul>
                    <li><strong><?php _e('Star Rating', 'tc-data-tables'); ?></strong> - <?php _e('TC_Star_Rating_Service is implemented (numeric → stars + click-editable inline) but grep -rEln "TC_Star_Rating_Service|gt-star" across templates/, assets/js/, admin/ returns only the service file.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Formula', 'tc-data-tables'); ?></strong> - <?php _e('TC_Formula_Service is implemented but only referenced from class-tc-csv-formula-detector.php for its SUPPORTED_FUNCTIONS constant — no rendering caller. Computed cells (sum/concat/conditional) won\'t appear in tables today.', 'tc-data-tables'); ?></li>
                </ul>
                <p><?php _e('Per-column Typography (font family, size, weight, line height) is wired through TC_Typography_Service in class-tc-shortcode.php. Per-column Vertical-Align is wired in v4.8.12 (#549 / #663) — admin select on the General tab of the field-config modal, runtime emit at templates/table.php. Per-table border preset (TC_Border_Service) is wired in v4.9.3 — Border style dropdown in Display Options with four named presets (classic / rows_only / none / outer_only) emitting scoped inline CSS.', 'tc-data-tables'); ?></p>
            </div>

            <div id="data-management-tools" class="gt-docs-section">
                <h2><?php _e('Search Tools', 'tc-data-tables'); ?></h2>

                <h3><?php _e('Fuzzy Search (working)', 'tc-data-tables'); ?></h3>
                <p><?php _e('The global search box supports fuzzy matching (typo tolerance) when the option is enabled per table. Wired through TC_Fuzzy_Search_Service: class-tc-ajax.php calls is_enabled() and build_meta_where() during the data-fetch query at lines 2231 and 2522. Useful for free-text columns where exact spelling varies.', 'tc-data-tables'); ?></p>

                <h3><?php _e('Find &amp; Replace', 'tc-data-tables'); ?></h3>
                <p><?php _e('Bulk find/replace across a table\'s entries, shipped end-to-end in the #538 slices. From Gravity Tables > All Tables, hover a table row and click the "Find / Replace" row action. The workflow is two-phase: first you enter the text to find and the plugin reports the match count via the gt_find_matches preview endpoint ("N matches found"); then you enter the replacement and confirm, and gt_apply_replace writes the changes back to the underlying Gravity Forms entries through GFAPI::update_entry. Both endpoints require the administrator (manage_options) capability and a valid nonce. Matching is exact-substring; the TC_Find_Replace_Service API also supports column scoping and case-sensitivity, which the row-action UI keeps at their defaults today (all text columns, case-sensitive) — a richer modal with a column-scope picker is tracked as follow-up work.', 'tc-data-tables'); ?></p>
            </div>

            <div id="data-sources" class="gt-docs-section">
                <h2><?php _e('External Data Sources', 'tc-data-tables'); ?></h2>
                <p><?php _e('Three external-source services exist in the codebase — TC_JSON_Source_Service, TC_Airtable_Field_Mapper, and an API key store (TC_API_Key_Service) — each with its own test contract pinning the include. None of them are wired into the table builder UI today: there is no "Add External Source" path, no Airtable/Notion connection screen, and no Settings → API Keys panel for credentials. Same orphan-with-test-contract pattern as multi-sort / skeleton / bulk-migration-bundle / AI tools (see the v4.7.156 / v4.7.158 / v4.7.162 / v4.7.163 changelog). All tables today come from Gravity Forms entries — registered_table_types defaults to ["gravity", "merged"] (verified in includes/class-tc-shortcode.php). Wiring up external sources properly is roadmap work; the service files are kept in place so they can be invoked from PHP and so the test suite stays green.', 'tc-data-tables'); ?></p>
            </div>

            <div id="ai-tools" class="gt-docs-section">
                <h2><?php _e('AI Tools', 'tc-data-tables'); ?></h2>
                <p><?php _e('Two AI helper services exist in the codebase — TC_AI_Cleanup_Suggester (deduplication helper) and TC_AI_Table_Summarizer (filtered-view summary) — but neither is currently wired into the admin UI or AJAX layer. There is no "Settings → API Keys" panel, no toolbar button, and no menu entry that exposes these to customers today. The service files ship with their own test contracts, so the implementations are runnable from PHP but cannot be exercised through the WP admin. This is the same orphan-with-test-contract pattern as TC_Multi_Sort_Service (v4.7.156), TC_Bulk_Migration_Bundle_Service (v4.7.158), and TC_Skeleton_Service (v4.7.162). The wire-up (Settings → API Keys panel, toolbar buttons, AJAX endpoints) is roadmap work. Customers wanting to try the underlying logic in the meantime can call the classes directly from a custom plugin or wp-cli script — see includes/services/class-tc-ai-cleanup-suggester.php and class-tc-ai-table-summarizer.php for the public method signatures.', 'tc-data-tables'); ?></p>
            </div>

            <div id="url-filters" class="gt-docs-section">
                <h2><?php _e('URL-Driven Filters', 'tc-data-tables'); ?></h2>
                <p><?php _e('TC_URL_Filter_Service (includes/services/class-tc-url-filter-service.php) is implemented to parse ?gt_col_{column_id}=value query parameters from the page URL — but no production code calls extract_filters() or is_enabled() on the service today. grep -rEn "TC_URL_Filter_Service|gt_col_" across includes/class-tc-ajax.php, templates/table.php, assets/js/frontend.js returns zero non-service-file consumers, and frontend.js has no history.pushState / URLSearchParams calls that update the URL when filters change. So the "shareable filtered URL" feature described in earlier versions of this guide is not currently wired up: opening ?gt_col_status=Active does not pre-filter the table, and changing filters in the UI does not update the URL bar. Same orphan-with-test-contract pattern as the running list (multi-sort v4.7.155/156, bulk-migration-bundle v4.7.158, skeleton v4.7.162, AI tools v4.7.163, external sources v4.7.164, pivot v4.7.165, drilldown v4.7.166, row-display batch v4.7.167, find-replace v4.7.168). Wiring up the service is roadmap. The Persistent Filters localStorage feature listed here previously is also not wired up — the table builder has no toggle for it and frontend.js does not read or write filter state to localStorage.', 'tc-data-tables'); ?></p>
            </div>

            <div id="print-export" class="gt-docs-section">
                <h2><?php _e('Print &amp; Export', 'tc-data-tables'); ?></h2>

                <h3><?php _e('Print', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><?php _e('Dedicated print stylesheet hides chrome (toolbar, pagination) so the printed page is just the data.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Per-table print settings let you choose page orientation, margins, and which columns to include in print.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('CSV Export', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><?php _e('Toolbar Export button downloads the current filtered view as CSV.', 'tc-data-tables'); ?></li>
                    <li><?php _e('CSV-formula injection is neutralized (cells starting with =, +, -, @ are prefixed with a single quote).', 'tc-data-tables'); ?></li>
                    <li><?php _e('Filename pattern is configurable per table (supports tokens like {table_name}, {date}).', 'tc-data-tables'); ?></li>
                    <li><?php _e('Large datasets stream to the browser via export_csv_streamed so memory usage stays bounded.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('Excel (.xlsx) Export', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><?php _e('Toolbar Export button can also download as native .xlsx via the bundled PhpSpreadsheet library.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Column number/date formatting is preserved.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Falls back to CSV automatically if PhpSpreadsheet is unavailable.', 'tc-data-tables'); ?></li>
                </ul>

                <p><em><?php _e('JSON export and server-side PDF are NOT shipped — for machine-readable output use the REST API; for PDF use your browser\'s print-to-PDF (the print stylesheet above is designed for it).', 'tc-data-tables'); ?></em></p>
            </div>

            <div id="conditional-formatting" class="gt-docs-section">
                <h2><?php _e('Conditional Formatting', 'tc-data-tables'); ?></h2>
                <p><?php _e('Apply visual styling to cells based on their values, making important data stand out.', 'tc-data-tables'); ?></p>

                <h3><?php _e('How to Set Up', 'tc-data-tables'); ?></h3>
                <ol>
                    <li><?php _e('In the table builder, click the gear icon on a column header to open its modal, then click the Conditional Formatting tab.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Click "Add Formatting Rule" and pick a condition. Supported conditions (verified against TC_Conditional_Formatting_Service::SUPPORTED_CONDITIONS): equals, not_equals, contains, greater_than, less_than, is_empty, is_not_empty. (Earlier versions of this guide listed "between" and "regex" — those are NOT shipped.)', 'tc-data-tables'); ?></li>
                    <li><?php _e('Choose styling options. Supported (verified in the service): background_color (hex), text_color (hex), bold, italic, and a free-form css_class hook for theme-side overrides. Per-rule border styling is NOT shipped.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Add multiple rules per field; rules are evaluated in order. Each rule that matches contributes its styles to the cell; later matching rules\' values override earlier ones for the same property.', 'tc-data-tables'); ?></li>
                </ol>

                <h3><?php _e('Examples', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><?php _e('Highlight high-priority items in red', 'tc-data-tables'); ?></li>
                    <li><?php _e('Mark completed items with a green background', 'tc-data-tables'); ?></li>
                    <li><?php _e('Bold text for important status values', 'tc-data-tables'); ?></li>
                    <li><?php _e('Highlight rows where a numeric value crosses a threshold', 'tc-data-tables'); ?></li>
                </ul>
            </div>

            <div id="lookup-fields" class="gt-docs-section">
                <h2><?php _e('Lookup Fields', 'tc-data-tables'); ?></h2>
                <p><?php _e('Lookup fields display and edit related data from WordPress users, posts, or custom tables instead of raw IDs.', 'tc-data-tables'); ?></p>

                <h3><?php _e('Automatic Configuration', 'tc-data-tables'); ?></h3>
                <p><?php _e('Specialized field types are auto-detected and configured:', 'tc-data-tables'); ?></p>
                <ul>
                    <li><?php _e('Driver Selector fields → user lookup', 'tc-data-tables'); ?></li>
                    <li><?php _e('AJS Lookup fields (from the AJS Trucking Toolkit) → custom-table lookup, no extra config required', 'tc-data-tables'); ?></li>
                    <li><?php _e('Other field types can be manually configured in the Advanced tab', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('Lookup Types', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('User Lookups', 'tc-data-tables'); ?></strong> - <?php _e('Display user names instead of IDs, with role filtering.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Post Lookups', 'tc-data-tables'); ?></strong> - <?php _e('Display post titles instead of post IDs, with post-type filtering.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Custom Lookups', 'tc-data-tables'); ?></strong> - <?php _e('Map values from any custom table (id_column → display_column).', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('Frontend Editing', 'tc-data-tables'); ?></h3>
                <p><?php _e('When editing a lookup cell on the frontend, users see a search-as-you-type input with suggestions from the lookup source. Pick a suggestion to auto-save the canonical value.', 'tc-data-tables'); ?></p>
            </div>

            <div id="customization" class="gt-docs-section">
                <h2><?php _e('Customization', 'tc-data-tables'); ?></h2>
                <p><?php _e('TableCrafter provides multiple layers of customization, from broad CSS framework choices down to per-column styling.', 'tc-data-tables'); ?></p>

                <h3><?php _e('🎨 CSS Framework Options', 'tc-data-tables'); ?></h3>
                <p><?php _e('Settings → CSS Framework exposes a dropdown with three values (Default / Minimal / None), but the value is currently saved without being consumed by the asset-enqueue path — every install loads the same default frontend stylesheet today regardless of which option is picked. Picking Minimal or None has no visible effect; the dropdown is being held in place pending the wire-up. To actually reduce the plugin\'s CSS footprint right now, add a `wp_dequeue_style` rule in your theme or a small mu-plugin keyed off the plugin\'s CSS handle. A proper "respect this setting at enqueue time" iteration is on the roadmap.', 'tc-data-tables'); ?></p>

                <h3><?php _e('🧱 Per-Column Styling (working)', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Width &amp; Alignment', 'tc-data-tables'); ?></strong> - <?php _e('Custom column widths and horizontal alignment via the column settings panel.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Wrap Mode', 'tc-data-tables'); ?></strong> - <?php _e('Truncate, wrap, or expand-on-hover behavior per column. TC_Wrap_Mode_Service is consumed by templates/table.php.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Typography', 'tc-data-tables'); ?></strong> - <?php _e('Font family, size, weight, line height per column or globally. TC_Typography_Service is consumed by class-tc-shortcode.php.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('📊 Layout (working)', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Sticky Headers', 'tc-data-tables'); ?></strong> - <?php _e('Per-table sticky_header setting; rendered via CSS-native position:sticky on th (no JS needed since v4.7.71).', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Mobile card view', 'tc-data-tables'); ?></strong> - <?php _e('Auto-converts to card layout below 768px / 480px breakpoints (assets/css/frontend.css + assets/js/frontend.js).', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('🛠️ Advanced &amp; Developer (working)', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Inline Custom CSS', 'tc-data-tables'); ?></strong> - <?php _e('Per-table custom CSS field. Sanitised on save via TC_Admin::sanitize_custom_css() and scoped on render via TC_Admin::scope_custom_css() (templates/table.php). The CSS is wrapped in #gt-table-<id> so it can\'t leak to other tables on the same page.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Conditional Formatting', 'tc-data-tables'); ?></strong> - <?php _e('Value-driven background/text color rules (see Conditional Formatting section).', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Developer Hooks', 'tc-data-tables'); ?></strong> - <?php _e('Action hooks and filters at render / save / entry-CRUD points. See docs/developer/hooks.md in the plugin source for the verified list (rewritten in v4.7.145).', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('Customization services and their current wire-up status', 'tc-data-tables'); ?></h3>
                <p><?php _e('The v4.8.x and v4.9.x audit thread closed several services that previously shipped only as runnable PHP with their own test contracts. The current state:', 'tc-data-tables'); ?></p>
                <p><strong><?php _e('Wired (live in admin/runtime):', 'tc-data-tables'); ?></strong></p>
                <ul>
                    <li><?php _e('CSS Framework — wired in v4.8.14. Settings → CSS Framework dropdown picks frontend.css / frontend-minimal.css / none.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Per-column Vertical Align — wired in v4.8.12 (#549 / #663). Per-column select on the field-config modal General tab.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Per-table Border preset (Borders service) — wired in v4.9.3. Border style dropdown in Display Options with four named presets.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Pagination Labels — wired in v4.8.19 + v4.8.20. Five-input "Pagination labels" feature group in Display Options.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Export Filename templates — wired in v4.8.17 (#634). Token-driven pattern input in Display Options.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Print Settings (paper size + repeat-header + row-striping + excluded-columns) — wired in v4.9.2 (#531). Print settings feature group in Display Options.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Multi-column sort — wired in v4.9.0 + v4.9.1 (#565). Shift-click in the toolbar with numbered badges; SQL ORDER BY honors the full stack.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Schema.org JSON-LD — wired in v4.7.66 (runtime emit) + v4.8.18 (admin UI). SEO / Structured Data dropdown in the table builder.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Page-cache invalidator — wired in tablecrafter.php (#550). Listens to save/import/delete + entry CRUD events; purges WP Rocket / W3TC / LiteSpeed / WP core caches for posts embedding the table.', 'tc-data-tables'); ?></li>
                    <li><?php _e('RTL — wired in tablecrafter.php (wp_enqueue_scripts) + templates/table.php wrapper (v4.9.5). On RTL sites the wrapper carries dir="rtl" + .gt-rtl class so frontend-rtl.css overrides land scoped to the table.', 'tc-data-tables'); ?></li>
                    <li><?php _e('URL pre-filtering — wired in v4.9.6. Per-table "Allow URL pre-filtering" toggle in Display Options (default OFF). When on, ?gt_col_{column_id}=value query params pre-populate the per-column filter inputs and seed this.filters before the first AJAX, so the table arrives pre-filtered. Reflected-XSS guard from #537 unchanged.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Per-column Star Rating cell type — wired in v4.9.7 (#162). Per-column Cell type select in the field-config modal General tab. AJAX cell-render loop calls TC_Star_Rating_Service::render($value) for star-rating columns; pre-rendered SVG HTML works without JS.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Per-column totals-row aggregation — wired in v4.9.8. New "Totals row aggregation" select in the field-config modal: Auto / Sum / Average / Min / Max / Count / Count distinct. frontend.js::updateColumnTotals reads per-column override and applies the matching reducer (mirrors TC_Formula_Service::compute_aggregation). Empty = legacy auto-Sum-for-numeric.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Per-table row height + header height + overflow mode — wired in v4.9.10. New "Row height" feature group in Display Options. Three named presets (compact 32px / default 48px / comfortable 64px) or a custom CSS value (px / em / rem / vh). Mobile cards mode reverts to auto.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Per-table default sort column + direction — wired in v4.9.11 (#115 / #391). New "Default sort" feature group in Display Options. Column dropdown driven by form fields + Asc/Desc direction. frontend.js init() overrides this.sortField/sortOrder before the first AJAX. Visitor session sort preference still takes precedence after click-to-sort.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Persistent filter state via browser localStorage — wired in v4.9.12. Per-table toggle in Display Options. Saves search box value + per-column filter selections to gt-filters-{table_id}. Restored before first AJAX. URL pre-filters take precedence. Off by default.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Whole-table collapsible toggle — wired in v4.9.13 (#177). Per-table "Collapsible table" feature group in Display Options. Toggle button above the table with aria-expanded + chevron icon; click toggles the body wrapper\'s hidden attribute; state persists to localStorage (gt_collapse_{table_id}). Off by default.', 'tc-data-tables'); ?></li>
                </ul>
                <p><strong><?php _e('Still orphan-with-test-contract (PHP-callable but no admin UI / runtime caller):', 'tc-data-tables'); ?></strong></p>
                <ul>
                    <li><?php _e('Per-row formula renderer (compute cell values from {field:N} expressions). Star Rating wired in v4.9.7; aggregation half of TC_Formula_Service wired in v4.9.8 (per-column totals row); per-row formula evaluation remains orphan.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Row Grouping, Sticky Rows, Cell Merge / Rowspan, Detail Rows (per-row group collapsible — different from the whole-table collapsible toggle wired in v4.9.13). Row Height wired in v4.9.10.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Toolbar Visibility (the existing scattered show_search / show_pagination / show_export toggles already cover most use cases).', 'tc-data-tables'); ?></li>
                    <li><?php _e('Find & Replace, Pivot Tables, Drilldown click-to-filter. (Persistent Filters localStorage wired in v4.9.12.)', 'tc-data-tables'); ?></li>
                    <li><?php _e('External Sources (JSON, Airtable), API Key store, AI helpers (Cleanup Suggester, Table Summarizer).', 'tc-data-tables'); ?></li>
                    <li><?php _e('Revision Snapshot history (Bulk Migration Bundle fully wired in v4.9.16 + v4.9.17 — slice 2a export + slice 2b import; #522 closed).', 'tc-data-tables'); ?></li>
                    <li><?php _e('Conditional Formatting service: client-side rendering is wired (assets/js/frontend.js applies styles per cell), but the server-side evaluation path that would carry formatting through to HTML exports is still orphan.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('💡 Customization Tips', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><?php _e('Default styling works well with most WordPress themes out of the box.', 'tc-data-tables'); ?></li>
                    <li><?php _e('To reduce the plugin\'s CSS footprint, pick "Minimal" or "None" in Settings → CSS Framework (wired in v4.8.14): Default loads frontend.css (~125KB), Minimal loads frontend-minimal.css (~3.4KB) for theme-styled sites, None registers the handle with no source so subsequent enqueues are no-ops.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Per-column overrides beat per-table; per-table beats global. Set the default at the broadest scope and override only where needed.', 'tc-data-tables'); ?></li>
                </ul>
            </div>

            <div id="datecoder-integration" class="gt-docs-section">
                <h2><?php _e('📅 DateCoder Tool Integration', 'tc-data-tables'); ?></h2>
                <p><?php _e('TableCrafter includes integrated links to the DateCoder tool for easy date and time format configuration.', 'tc-data-tables'); ?></p>

                <h3><?php _e('Accessing DateCoder', 'tc-data-tables'); ?></h3>
                <p><?php _e('In Settings → date/time format fields, the calendar/clock icon buttons open DateCoder in a new tab.', 'tc-data-tables'); ?></p>

                <h3><?php _e('Common Formats', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><code>m/d/Y</code> → 12/31/2026</li>
                    <li><code>F j, Y</code> → December 31, 2026</li>
                    <li><code>g:i A</code> → 3:45 PM</li>
                </ul>
            </div>

            <div id="mobile-responsiveness" class="gt-docs-section">
                <h2><?php _e('Mobile Responsiveness', 'tc-data-tables'); ?></h2>
                <p><?php _e('Comprehensive mobile responsiveness with card-based layouts and field visibility controls:', 'tc-data-tables'); ?></p>

                <h3><?php _e('Card View System', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><?php _e('Automatic conversion to card layout on mobile devices', 'tc-data-tables'); ?></li>
                    <li><?php _e('Collapsible sections for hidden fields', 'tc-data-tables'); ?></li>
                    <li><?php _e('Date-first layout optimization', 'tc-data-tables'); ?></li>
                    <li><?php _e('Touch-friendly controls', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('Quick Visibility Toggles', 'tc-data-tables'); ?></h3>
                <p><?php _e('One-click mobile/tablet visibility controls in the field list. Blue = visible, gray = hidden. No modal required for basic visibility changes.', 'tc-data-tables'); ?></p>

                <h3><?php _e('Responsive Breakpoints', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><?php _e('Mobile: 480px and below', 'tc-data-tables'); ?></li>
                    <li><?php _e('Tablet: 768px and below', 'tc-data-tables'); ?></li>
                    <li><?php _e('Desktop: above 768px', 'tc-data-tables'); ?></li>
                </ul>
            </div>

            <div id="troubleshooting" class="gt-docs-section">
                <h2><?php _e('Troubleshooting', 'tc-data-tables'); ?></h2>

                <h3><?php _e('General Issues', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Tables not displaying', 'tc-data-tables'); ?></strong> - <?php _e('Verify Gravity Forms is activated and the form has entries.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Shortcode not working', 'tc-data-tables'); ?></strong> - <?php _e('Check the table ID is correct.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Permission errors', 'tc-data-tables'); ?></strong> - <?php _e('Ensure users have an appropriate role in the table\'s access control.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Plugin conflicts', 'tc-data-tables'); ?></strong> - <?php _e('Deactivate other plugins temporarily to isolate.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('Frontend Editing Issues', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e("Can't click empty fields", 'tc-data-tables'); ?></strong> - <?php _e('Hover over empty cells to see "Click to edit" prompt.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Changes not saving', 'tc-data-tables'); ?></strong> - <?php _e('Press Enter to save, Escape to cancel. Verify JavaScript is enabled and check browser console for errors.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Lookup search not appearing', 'tc-data-tables'); ?></strong> - <?php _e('Confirm the source plugin (e.g. AJS Trucking Toolkit for ajs_lookup, or driver-selector for user lookups) is active.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('Filter Issues', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('No filter options', 'tc-data-tables'); ?></strong> - <?php _e('Filters populate from existing entries — ensure the form has data.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Date filters not working', 'tc-data-tables'); ?></strong> - <?php _e('Click the date input or use preset buttons (Today, Week, Month).', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('Detail Row Issues', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Eye icon not visible', 'tc-data-tables'); ?></strong> - <?php _e('The detail icon is rendered by frontend.js when config.detail_label is set; check the per-table setting and clear any aggressive page cache that might be serving stale HTML/JS.', 'tc-data-tables'); ?></li>
                </ul>

                <p><?php _e('Drilldown click-to-filter and Pivot Tables are not currently wired up — see the Click-to-Filter Drilldown and Pivot Tables sections above. The "URL filter persists across navigation" troubleshooting tip listed in earlier versions of this guide also referenced URL-Driven Filters, which is also not currently wired up (v4.7.169). Removing those bullets here so the troubleshooting list only covers features that ship.', 'tc-data-tables'); ?></p>

                <h3><?php _e('Performance Issues', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><?php _e('Reduce per_page or column count for very wide / long tables.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Cache plugins can stale-cache the data — exclude /admin-ajax.php from cache rules.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('Getting Help', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><?php _e('Check the browser console for JavaScript errors.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Open Advanced Tables → Debug Logs (sidebar) to view recent plugin log entries written by class-tc-logger.php.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Verify WordPress and Gravity Forms are up to date.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Test with a default WordPress theme to rule out theme conflicts.', 'tc-data-tables'); ?></li>
                </ul>
            </div>

            <div id="permission-troubleshooting" class="gt-docs-section">
                <h2><?php _e('Permission Troubleshooting', 'tc-data-tables'); ?></h2>
                <p><?php _e('If users with correct roles still hit access errors, work through these steps:', 'tc-data-tables'); ?></p>

                <ol>
                    <li><?php _e('Verify user roles in WordPress → Users.', 'tc-data-tables'); ?></li>
                    <li><?php _e('In the table builder, scroll to the User Permissions feature group and confirm the user\'s role is in the "Table Access Control" multi-select. (Earlier versions of this guide said "General tab → Access Control" — that was misleading; the control lives in the main table builder area, not in a field-level tab.)', 'tc-data-tables'); ?></li>
                    <li><?php _e('Look for multiple active table configurations targeting the same form. Older "AJS Trucking Toolkit" sites had a known issue here — only one active table per form/role.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Enable WordPress debug logging (define WP_DEBUG and WP_DEBUG_LOG in wp-config.php) and watch wp-content/debug.log while reproducing the issue.', 'tc-data-tables'); ?></li>
                    <li><?php _e('Test with different user roles to isolate whether the issue is role-specific or universal.', 'tc-data-tables'); ?></li>
                </ol>

                <h3><?php _e('Common Causes', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('JavaScript querySelector errors', 'tc-data-tables'); ?></strong> - <?php _e('Often a downstream symptom of a backend permission denial.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Intermittent "Access denied" messages', 'tc-data-tables'); ?></strong> - <?php _e('Multiple matching table configurations being selected non-deterministically.', 'tc-data-tables'); ?></li>
                </ul>
            </div>

            <div id="whats-new" class="gt-docs-section">
                <h2><?php printf(esc_html__('What\'s New (v%s)', 'tc-data-tables'), defined('TC_VERSION') ? TC_VERSION : '4.x'); ?></h2>
                <p><?php _e('Highlights of what\'s landed across recent versions. Full per-version detail lives in CHANGELOG.md and readme.txt.', 'tc-data-tables'); ?></p>

                <h3><?php _e('v8.0.42 -- New validation rules: oneOf, notOneOf, phone, unique', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Value-list rules', 'tc-data-tables'); ?></strong> -- <?php _e('oneOf restricts a cell to an allowed list of values; notOneOf blocks specific values.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Phone validation', 'tc-data-tables'); ?></strong> -- <?php _e('validate phone numbers with the built-in format check or your own custom pattern.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Unique values', 'tc-data-tables'); ?></strong> -- <?php _e('the unique rule rejects duplicates with an instant in-browser check plus an authoritative server-side check that ignores the row being edited.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Developer hook', 'tc-data-tables'); ?></strong> -- <?php _e('the new tablecrafter_validate_cell filter lets developers add custom cell validation rules.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.41 -- JSON export, advanced search operators, inline cell-type parity', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('JSON export', 'tc-data-tables'); ?></strong> -- <?php _e('the export button now offers CSV, Excel, and JSON. JSON export applies the same column visibility and search filters as the other formats.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Advanced search grammar', 'tc-data-tables'); ?></strong> -- <?php _e('the search box on standard tables now accepts quoted phrases, -word exclusions, field:value targeting, and numeric comparisons (>, <, >=, <=). Parsed client-side with no extra server requests.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Inline cell-type parity', 'tc-data-tables'); ?></strong> -- <?php _e('url, datetime, multiselect, checkbox_group, and color cells now use dedicated input widgets with type-aware validation when editing inline, matching the display-mode renderers.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.40 -- All Tables list shows each table\'s data source', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('New Source column', 'tc-data-tables'); ?></strong> -- <?php _e('the All Tables screen now shows each table\'s actual data source (Gravity Forms with the form name, JSON / REST, CSV, Google Sheets, Airtable, Notion, External Database, WooCommerce) instead of the old "Gravity Form" column, which showed "Form ID: 0" for every non-Gravity-Forms table.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.39 -- External DB public viewing + readable builder chips', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('External Database tables: public viewing opt-in', 'tc-data-tables'); ?></strong> -- <?php _e('each External DB table now has an "Allow public viewing" checkbox in the builder. When enabled, logged-out visitors see the table normally. When disabled, they see a clean "This table is not available." message instead of a blank or broken page.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Readable field-picker chips for JSON, CSV, XML, and Google Sheets', 'tc-data-tables'); ?></strong> -- <?php _e('the column picker now shows humanized labels ("Product Name" instead of product_name) for all external sources, matching the behaviour added for Airtable, Notion, and External DB in 8.0.36-8.0.37.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.38 -- Dashboard stats you can trust', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Redesigned stats cards', 'tc-data-tables'); ?></strong> -- <?php _e('Total, In use (tables actually embedded on a page), one card per data source in use, and Trash (hidden when empty, matching the Trash tab exactly). The old GF Tables card showed the wrong number.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Trash self-heal', 'tc-data-tables'); ?></strong> -- <?php _e('tables deleted before the Trash system existed were invisible everywhere; they now surface in the Trash tab as restorable entries.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.37 -- Airtable / Notion / External DB in the builder', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Load columns + live preview', 'tc-data-tables'); ?></strong> -- <?php _e('Airtable, Notion, and External Database sources now have a "Load columns" button in the builder that connects with your entered (or saved) credentials, fills the field picker, and shows a live sample preview -- no save-first required. Saved tables auto-load on edit.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('External Database rendering fixed', 'tc-data-tables'); ?></strong> -- <?php _e('the capability gating external-DB queries was never registered, so these tables never rendered for anyone. It is now granted to administrators automatically and manageable per role.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.36 -- Readable external-source headers', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Friendly column headers', 'tc-data-tables'); ?></strong> -- <?php _e('external-source tables (JSON, CSV, XML, Airtable, Notion, External DB) now show readable headers like "Product Name" instead of raw keys, in both the frontend and the builder preview. Saved labels still win; acronyms (ID, URL, SKU) stay upper-case.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.35 — Cleaner table builder', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Less clutter in the builder', 'tc-data-tables'); ?></strong> — <?php _e('removed the redundant per-step Save buttons; the floating Save covers all steps.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.34 — Accurate dashboard table count', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Dashboard "Total" no longer counts trashed tables', 'tc-data-tables'); ?></strong> — <?php _e('the Total stat was inflated by soft-deleted tables (e.g. 26 vs 4 active); it now shows only your real tables.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.33 — Correct Pro update source', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Pro no longer offered the free version as an update', 'tc-data-tables'); ?></strong> — <?php _e('WordPress was showing the wordpress.org free build as an update on Pro sites; updates now come through your license.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.32 — Server-side processing fix', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Server-side tables show their rows again', 'tc-data-tables'); ?></strong> — <?php _e('tables set to server-side processing were rendering empty; the entry fetch is now wired up correctly.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.31 — WooCommerce builder (Pro)', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Build WooCommerce tables easily', 'tc-data-tables'); ?></strong> — <?php _e('the builder now auto-loads product columns into the field picker and shows a live preview.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.30 — WooCommerce tables render (Pro)', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('WooCommerce product tables fixed', 'tc-data-tables'); ?></strong> — <?php _e('they showed "Gravity Forms is required"; now they render product links, prices, stock and add-to-cart.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.29 — Welcome page fixes', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Readable notices + working template buttons', 'tc-data-tables'); ?></strong> — <?php _e('the Welcome notice no longer hides inside the header, the "Create this table" buttons confirm success, and the account upgrade button is on-brand.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.28 — Button fix + on-brand Welcome', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Upgrade button stays in its card', 'tc-data-tables'); ?></strong> — <?php _e('the License & Account CTA no longer overflows on narrow widths.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('On-brand Welcome screen', 'tc-data-tables'); ?></strong> — <?php _e('the "Start from a template" page now uses the TableCrafter look.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.27 — Rate-plugin link fixed', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Leave a review works', 'tc-data-tables'); ?></strong> — <?php _e('the "Rate plugin" link now opens the WordPress.org review form.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.26 — Accurate upgrade page', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Corrected License &amp; Account copy', 'tc-data-tables'); ?></strong> — <?php _e('right price ($7.99/mo), right trial length (7 days), and a Pro feature list that no longer lists Free benefits.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.25 — On-brand upgrade card', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Redesigned upgrade card', 'tc-data-tables'); ?></strong> — <?php _e('on-brand look with an accurate Pro pitch (editing, bulk ops, advanced filters, premium sources) — unlimited tables stays a Free perk.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.24 — Correct tier copy + license helper', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Accurate "Gravity Forms not active" notice', 'tc-data-tables'); ?></strong> — <?php _e('lists the real free sources (JSON, CSV, Google Sheets, Excel) and notes Gravity Forms tables are Pro.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('License-key helper', 'tc-data-tables'); ?></strong> — <?php _e('the license-activation screen now points you to where to find your key.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.23 — Free activation fix', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Freemius opt-in', 'tc-data-tables'); ?></strong> — <?php _e('fresh free installs now connect or skip cleanly instead of erroring with an invalid-license message.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.22 — Free vs Pro boundary', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Premium integrations are now Pro', 'tc-data-tables'); ?></strong> — <?php _e('Gravity Forms, WooCommerce and Airtable join Notion, XML and External DB as Pro sources. The free tier covers JSON, CSV, Google Sheets and Excel. Free users opening a Pro-source table get an upgrade prompt.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.21 — Runs on PHP 8.1+ (packaging fix)', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('PHP compatibility fix', 'tc-data-tables'); ?></strong> — <?php _e('A packaging issue made the plugin demand PHP 8.3; it now correctly runs on PHP 8.1 and up, fixing a fatal error on 8.1/8.2 sites.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.20 — Inline auto-refresh fix', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Auto-refresh works again (#2143)', 'tc-data-tables'); ?></strong> — <?php _e('The auto_refresh / refresh_interval settings on inline tables were being dropped before taking effect; live tables now poll and update on schedule. Caught by a browser smoke of the free build.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.19 — Support area (Pro, foundation)', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Support threads (#2159)', 'tc-data-tables'); ?></strong> — <?php _e('A new Pro Support area to manage customer support threads — phase one of an AI-assisted support system with human takeover coming next.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.18 — Faster large tables', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('DOM virtualization (#2131)', 'tc-data-tables'); ?></strong> — <?php _e('External-source tables with thousands of rows now keep only the visible page in the DOM, so sorting, filtering, and paging stay smooth.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.17 — Start from a template', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Prebuilt templates gallery (#2134)', 'tc-data-tables'); ?></strong> — <?php _e('Five one-click templates (Inventory, Business Directory, CRM Pipeline, Event List, Load Tracker) create a ready-to-edit table with sample data and columns already set up.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.16 — Embed tables anywhere', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Embeddable public tables (#2133)', 'tc-data-tables'); ?></strong> — <?php _e('Each table has a "Copy embed code" button that gives you an iframe snippet to drop on any site. The embedded view is public and read-only, with a small "Made with TableCrafter" link (removed on Pro).', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.15 — Beautiful auto-formatted cells', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Rich auto-formatting (#2132)', 'tc-data-tables'); ?></strong> — <?php _e('External-source tables now format cells automatically: ISO dates become readable, large numbers get thousands separators, and URLs become clickable links. Conservative by design — 4-digit years and short IDs are left as-is.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.14 — Free vs Pro clarity', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Plans: Free vs Pro section (#2162)', 'tc-data-tables'); ?></strong> — <?php _e('A new docs section badges every data source and feature as Free or Pro (sources are read live from the registry, so it is always accurate). Pro rows link straight to upgrade.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Contextual upsell in the builder (#2118)', 'tc-data-tables'); ?></strong> — <?php _e('Pick a Pro-only data source and an in-context note explains it is Pro, with an upgrade link — right at the moment of need.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.12 — Inline upgrade back-compat (auto-refresh, Airtable, uninstall)', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Inline auto-refresh restored (#2143)', 'tc-data-tables'); ?></strong> — <?php _e('The classic auto_refresh / refresh_interval / refresh_indicator / refresh_countdown / refresh_last_updated settings work again: inline tables poll the source and update in place, with an indicator, countdown and last-updated status.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Inline Airtable restored (#2148)', 'tc-data-tables'); ?></strong> — <?php _e('Pages using source="airtable://base/table?token=..." render again. Airtable display stays a free source.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Admin slug + CSS class back-compat (#2147)', 'tc-data-tables'); ?></strong> — <?php _e('Old admin bookmarks redirect instead of 404ing, and inline tables keep the classic .tablecrafter-container wrapper class for existing theme CSS.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Clean uninstall (#2146)', 'tc-data-tables'); ?></strong> — <?php _e('Deleting the plugin now removes its tables, options and scheduled tasks (multisite-aware).', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.3 — Refreshed "What\'s new" notice', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Updated highlights notice (#2111)', 'tc-data-tables'); ?></strong> — <?php _e('The one-time admin notice now describes the v8 product instead of v7, and re-appears once for anyone who had dismissed the older v7 notice.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.2 — Google Sheets demo + builder polish', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Google Sheets one-click demo (#2106)', 'tc-data-tables'); ?></strong> — <?php _e('The getting-started demos now include a Students (Google Sheets) table, so you can see the Sheets integration in one click. It points at Google\'s long-stable public sample sheet. Requires internet; the JSON/CSV demos still work offline.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Gravity Form picker is Gravity-Forms-only (#2108)', 'tc-data-tables'); ?></strong> — <?php _e('The table builder no longer shows the Gravity Form dropdown when the data source is Google Sheets, JSON, CSV, XML, Airtable, Notion, External DB or Excel. It appears only for the Gravity Forms entries source.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.1 — External DB connection management (#2040)', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Database Connections screen', 'tc-data-tables'); ?></strong> — <?php _e('A new Pro admin screen lists your MySQL / SQL Server connections and lets you add them with a Test connection button and per-row Delete. Saved connections show up in the External DB data-source selector in the table builder, so the source now works end to end. Credentials are encrypted at rest.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Blank port defaults correctly', 'tc-data-tables'); ?></strong> — <?php _e('Leaving the Port field empty now uses the driver default (3306 for MySQL, 1433 for SQL Server) instead of storing 0.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v8.0.0 — Convergence: one source-agnostic product + demo onboarding', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('One source-agnostic product', 'tc-data-tables'); ?></strong> — <?php _e('The free and premium codebases are converged into a single license-gated plugin. Gravity Forms is now one data source among many, not the spine. Classes and files were rebranded GT_ to TC_; legacy names keep working via a back-compat alias autoloader.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('One-click demo tables', 'tc-data-tables'); ?></strong> — <?php _e('A getting-started panel and first-activation welcome screen create ready-to-view demo tables (Users / Products / Metrics JSON + Employees CSV) in a single click, with columns pre-selected so they render immediately.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('CSV / XML / Google Sheets builder parity', 'tc-data-tables'); ?></strong> — <?php _e('These remote sources now auto-load their columns on edit and preview inline in the builder, matching the JSON experience.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Demos work on local hosts', 'tc-data-tables'); ?></strong> — <?php _e('Bundled demo files are read from disk, bypassing the outbound SSRF guard that blocks loopback URLs, so Test connection and rendering work in local development.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Builder polish', 'tc-data-tables'); ?></strong> — <?php _e('Saved remote-source columns auto-load on edit (no manual Test connection), and the native dropdown arrow is restored on builder selects.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Feature-based gating', 'tc-data-tables'); ?></strong> — <?php _e('The free tier is gated by feature, not by row or column count; the old size caps were removed.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.8.4 — Wizard JSON fix + streaming parser + title/builder-ID fixes (#1988, #1990, #1992)', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Test Connection now works', 'tc-data-tables'); ?></strong> — <?php _e('Wizard step 2 was posting the JSON URL under the wrong key so PHP never received it, returning an object error that rendered as [object Object]. Fixed the FormData key and the JS error display.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Streaming JSON parser', 'tc-data-tables'); ?></strong> — <?php _e('Large JSON files (5MB+) stream-parsed via halaxa/json-machine. fetch_preview_rows() adds Range header + early exit after 5 rows, dropping the TCP connection early. Fixes 15-second timeouts in both the wizard and the builder Preview Area.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Table title now saved from wizard (#1990)', 'tc-data-tables'); ?></strong> — <?php _e('FormData key corrected from "table_title" to "title"; form_id sent as a top-level field. Both now land correctly in the gravity_tables database row.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('"Customize in builder" opens the right table (#1992)', 'tc-data-tables'); ?></strong> — <?php _e('After a new table was created, the revision snapshot hook called update_option() which inserted into wp_options, corrupting $wpdb->insert_id. The AJAX handler now uses the ID returned directly by save_table() instead.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('"Loading fields..." spinner hidden after columns load (#1988)', 'tc-data-tables'); ?></strong> — <?php _e('The step 3 column loading spinner is now hidden as soon as renderColumnList() writes the column checkboxes.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.8.0 — Documentation page redesign: collapsible cards, teal hero, styled sidebar (#1976)', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Collapsible section cards', 'tc-data-tables'); ?></strong> — <?php _e('All 27 documentation sections are now expandable cards with a clickable header, chevron indicator, and icon. Getting Started and What\'s New open by default.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Teal hero banner', 'tc-data-tables'); ?></strong> — <?php _e('Documentation page opens with the brand teal gradient hero, version badge, and subtitle — matching the dashboard, license, and account pages.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Styled sticky sidebar', 'tc-data-tables'); ?></strong> — <?php _e('Left nav is a polished card with scroll-spy (active link highlights as you scroll) and click-to-expand integration.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Restored docs CSS', 'tc-data-tables'); ?></strong> — <?php _e('Getting Started intro callout, feature-card grid, and prereqs styles from v7.7.6 are restored in the dedicated admin-docs.css file.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.7.9 — Account page brand redesign (#1974)', 'tc-data-tables'); ?></h3>
                <p><?php _e('The Freemius account page now shows a teal gradient hero banner with Pro/Free badge, matching the dashboard and license page. Headings, code snippets, links, and buttons all use the brand teal palette.', 'tc-data-tables'); ?></p>

                <h3><?php _e('v7.7.8 — License page brand redesign (#1973)', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Teal brand hero', 'tc-data-tables'); ?></strong> — <?php _e('Full teal gradient banner replaces the plain WP-blue title. Admin notices appear above the hero (not inside it) via correct h1 placement.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Enticing sales layout', 'tc-data-tables'); ?></strong> — <?php _e('"Most Popular" featured card with 8-item Dashicons feature list and a prominent Buy Pro CTA. Two compact sidebar cards for trial and license-key entry.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Freemius safety', 'tc-data-tables'); ?></strong> — <?php _e('All SDK method calls wrapped in method_exists() guards; compatible with older Freemius versions.', 'tc-data-tables'); ?></li>
                </ul>

                                <h3><?php _e('v7.7.7 — Dashboard fixes: data-source counts, WP.org URLs, Pro-gate (#1972)', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Real counts', 'tc-data-tables'); ?></strong> — <?php _e('Data Sources widget parses settings JSON per active table; shows "X tables" or "Not in use" for GF, JSON/REST, Airtable, Notion.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('WP.org URLs fixed', 'tc-data-tables'); ?></strong> — <?php _e('Rate Plugin and Support Forum now link to the correct tablecrafter-wp-data-tables slug.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Upgrade to Pro gated', 'tc-data-tables'); ?></strong> — <?php _e('Quick link hidden for licensed users.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.7.6 — Documentation: Getting Started redesign (#1970)', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Teal intro callout', 'tc-data-tables'); ?></strong> — <?php _e('Replaced plain paragraph with a teal left-border callout box for immediate visual impact.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Feature-card grid', 'tc-data-tables'); ?></strong> — <?php _e('11 feature highlights displayed as a responsive 3-column Dashicons card grid (stacks on mobile).', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Styled prereqs', 'tc-data-tables'); ?></strong> — <?php _e('Prerequisites list uses teal checkmark icons for a polished, scannable layout.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.7.5 — Admin menu uses plugin teal color (#1968)', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Visual polish', 'tc-data-tables'); ?></strong> — <?php _e('The TableCrafter sidebar entry highlights with brand teal (#0d9488) on active and hover states, matching the dashboard hero and card accents.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.7.4 — Dashboard widget fixes: license, docs link, GF version, changelog (#1965, #1966)', 'tc-data-tables'); ?></h3>
<ul>
    <li><strong><?php _e('License widget', 'tc-data-tables'); ?></strong> — <?php _e('Now uses gt_is_premium() (consistent with the rest of the plugin) instead of a non-existent gt_fs() call; licensed sites show "Pro — Active".', 'tc-data-tables'); ?></li>
    <li><strong><?php _e('In-plugin docs link', 'tc-data-tables'); ?></strong> — <?php _e('Fixed wrong slug (gravity-tables-documentation → gravity-tables-docs); link no longer 404s.', 'tc-data-tables'); ?></li>
    <li><strong><?php _e('GF version', 'tc-data-tables'); ?></strong> — <?php _e('Changed function_exists("GFCommon") to class_exists("GFCommon"); GF version now displays correctly in System Info.', 'tc-data-tables'); ?></li>
    <li><strong><?php _e('Changelog widget', 'tc-data-tables'); ?></strong> — <?php _e('Updated from stale v7.5.x entries to the three most recent releases.', 'tc-data-tables'); ?></li>
</ul>

                <h3><?php _e('v7.7.3 — Dashboard CSS now loads correctly (#1958)', 'tc-data-tables'); ?></h3>
<ul>
    <li><strong><?php _e('Bug fix', 'tc-data-tables'); ?></strong> — <?php _e('The admin dashboard card layout, hero banner, and stat widgets were rendering as plain text because the CSS was not enqueued. WordPress derives the admin hook suffix from the menu title ("tablecrafter"), not the slug ("gravity-tables"), so the enqueue guard was skipping the dashboard page. The guard now accepts both prefixes.', 'tc-data-tables'); ?></li>
</ul>

                <h3><?php _e('v7.7.2 — Auto-migrate stale [gravity_table] shortcodes on first admin load (#1957)', 'tc-data-tables'); ?></h3>
<ul>
    <li><strong><?php _e('Bug fix', 'tc-data-tables'); ?></strong> — <?php _e('Tables created before v7.6.3 had [gravity_table id="X"] stored in the database. A one-time migration on the first admin_init after upgrading now rewrites those rows to [tablecrafter id="X"]. The migration is idempotent — it runs exactly once per site.', 'tc-data-tables'); ?></li>
</ul>

                <h3><?php _e('v7.7.1 — JSON source preview auto-renders on page load (#1959)', 'tc-data-tables'); ?></h3>
<ul>
    <li><strong><?php _e('Bug fix', 'tc-data-tables'); ?></strong> — <?php _e('When a JSON URL is already saved, reloading the table builder page now automatically renders the preview instead of showing "Enter a JSON URL and click Test Connection". Preview also triggers automatically when available fields load via Test Connection.', 'tc-data-tables'); ?></li>
</ul>

                <h3><?php _e('v7.7.0 — Admin builder: sections 1 and 4 open by default (#1953)', 'tc-data-tables'); ?></h3>
<ul>
    <li><strong><?php _e('UX fix', 'tc-data-tables'); ?></strong> — <?php _e('Opening the table builder now shows only the Data Source and Preview sections expanded. Field Selection and Table Features start collapsed so the most-used controls are immediately visible.', 'tc-data-tables'); ?></li>
</ul>

                <h3><?php _e('v7.6.9 — Fix: JSON source preview works in admin table builder', 'tc-data-tables'); ?></h3>
<ul>
    <li><strong><?php _e('Bug fix', 'tc-data-tables'); ?></strong> — <?php _e('Preview panel no longer shows "Missing required form_id" for JSON sources. A live fetch preview is now rendered from the configured URL.', 'tc-data-tables'); ?></li>
</ul>

                <h3><?php _e('v7.6.8 — Fix: Shortcode displayed after save now uses canonical [tablecrafter] name', 'tc-data-tables'); ?></h3>
<ul>
    <li><strong><?php _e('Bug fix', 'tc-data-tables'); ?></strong> — <?php _e('Saving a table now stores and displays [tablecrafter id="X"] instead of the deprecated [gravity_table id="X"] in the builder UI and the database.', 'tc-data-tables'); ?></li>
</ul>

                <h3><?php _e('v7.6.7 — Column selection for JSON / Airtable / Notion tables', 'tc-data-tables'); ?></h3>
<ul>
    <li><strong><?php _e('Column picker', 'tc-data-tables'); ?></strong> — <?php _e('After clicking Test Connection, the inferred columns populate the Available Fields picker. Drag to Table Columns to choose which appear and in what order. The renderer now honours that selection.', 'tc-data-tables'); ?></li>
    <li><strong><?php _e('Preview fix', 'tc-data-tables'); ?></strong> — <?php _e('Preview panel no longer shows "Please select a form" for external-source tables.', 'tc-data-tables'); ?></li>
</ul>

<h3><?php _e('v7.6.6 — Fix: No Available Fields shown for JSON/Airtable/Notion data sources', 'tc-data-tables'); ?></h3>
<ul>
    <li><strong><?php _e('Bug fix', 'tc-data-tables'); ?></strong> — <?php _e('The field-picker step is now hidden when Data Source is set to JSON, Airtable, or Notion. An informational notice explains that columns are detected automatically from the remote data.', 'tc-data-tables'); ?></li>
</ul>

<h3><?php _e('v7.6.5 — Fix: JSON/Airtable/Notion save blocked by Gravity Form validation', 'tc-data-tables'); ?></h3>
<ul>
    <li><strong><?php _e('Bug fix', 'tc-data-tables'); ?></strong> — <?php _e('Saving a table with Data Source set to JSON / Airtable / Notion no longer triggers the "Please select a Gravity Form" error. Both the JS and PHP validators now skip form-specific checks when the source type is not gravity_forms.', 'tc-data-tables'); ?></li>
</ul>

<h3><?php _e('v7.6.4 — Global admin notice for v7.x highlights', 'tc-data-tables'); ?></h3>
<ul>
    <li><strong><?php _e('Site-wide What\'s New notice', 'tc-data-tables'); ?></strong> — <?php _e('The v7.x highlights notice now appears on every TableCrafter admin page (tables list, builder, docs, settings) instead of only the tables list. Dismisses once per user.', 'tc-data-tables'); ?></li>
</ul>

<h3><?php _e('v7.6.3 — [gravity_table] and [gravity_tables] shortcodes deprecated', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Shortcode deprecation', 'tc-data-tables'); ?></strong> — <?php _e('[gravity_table] and [gravity_tables] now fire _doing_it_wrong() and will be removed in 8.0. Use [tablecrafter] in all new content. A one-time DB migration automatically rewrites stored shortcodes on upgrade.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.6.2 — i18n text domain renamed to tc-data-tables', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Text domain rename (#1934)', 'tc-data-tables'); ?></strong> — <?php _e('All i18n calls now use \'tc-data-tables\'. Plugin header Text Domain, load_plugin_textdomain(), and block.json textdomain updated. Frozen identifiers (Freemius slug, admin menu slug, asset handles, option prefixes) remain \'gravity-tables\'.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.6.1 — Airtable data source discovery', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Airtable base/table discovery (#1920)', 'tc-data-tables'); ?></strong> — <?php _e('TC_Airtable_Sync_Engine gains list_bases() and list_tables() static methods that call the Airtable Metadata API to enumerate accessible bases and their tables. Admin config UIs can now offer live dropdowns instead of requiring raw ID entry.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.6.0 — WooCommerce orders data source + inline status write-back', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('WooCommerce orders source (#1914)', 'tc-data-tables'); ?></strong> — <?php _e('TC_WooCommerce::get_order_table_entries() queries WC orders and returns rows with ID, date, customer, status, total, items, and payment method. Supports paging, sorting, and status/search filters.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Order status write-back (#1922)', 'tc-data-tables'); ?></strong> — <?php _e('update_order_status() calls $order->update_status() which fires woocommerce_order_status_changed, sends WC emails, and logs a note — all via the canonical WC API, no direct DB writes.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.5.9 — Email export: CSV attachments and scheduled delivery', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Attach CSV to alerts (#1913)', 'tc-data-tables'); ?></strong> — <?php _e('Alert rules now support an attach_csv flag. When enabled, the threshold-alert email includes a CSV export of current table rows as an attachment.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Scheduled export delivery (#1917)', 'tc-data-tables'); ?></strong> — <?php _e('schedule_export() registers a WP cron event delivering a CSV to a recipient on a daily/weekly/monthly schedule. send_scheduled_export() is the cron callback; unschedule_export() cancels it.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.5.8 — REST API paginated response handling', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('REST API pagination (#1919)', 'tc-data-tables'); ?></strong> — <?php _e('TC_JSON_Source_Service::fetch_paginated() follows multi-page REST APIs via RFC 5988 Link headers (rel="next") and body next-URL fields (next, next_url, links.next, etc.). Configurable max_pages ceiling (default 50) prevents runaway fetches; SSRF guard applied on every page URL.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.5.7 — Mobile touch-tap inline editing', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Touch-tap editing (#1921)', 'tc-data-tables'); ?></strong> — <?php _e('Editable cells now register touchstart/touchend handlers. A 10px movement guard prevents scroll from opening an editor. preventDefault on the confirmed tap suppresses the synthetic click so editField fires only once — no 300ms delay on older iOS.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.5.6 — Auto-refresh last-updated timestamp', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Auto-refresh timestamp (#1912)', 'tc-data-tables'); ?></strong> — <?php _e('Tables with auto_refresh_interval > 0 now show a "Last refreshed Xs ago" label below the table after each auto-refresh cycle. The relative timestamp updates every 30 seconds without additional server requests.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.5.5 — Full admin dashboard with 7 widgets', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Dashboard widgets (#1924-#1931)', 'tc-data-tables'); ?></strong> — <?php _e('Hero (version + CTA), Tables stats, Data sources status, License/Account, System info, Changelog, and Quick Links widgets are all live at ?page=tablecrafter-dashboard. Dedicated admin-dashboard.css loaded only on the dashboard page.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.5.4 — Backward-compat redirects for renamed admin slugs', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Legacy slug redirects (#1935)', 'tc-data-tables'); ?></strong> — <?php _e('redirect_legacy_slugs() fires on admin_init and transparently forwards renamed wp-admin page slugs to their new canonical URLs (e.g. ?page=gravity-tables-dashboard → ?page=tablecrafter-dashboard). Adding future slug renames is a one-line change to the legacy map.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.5.3 — Dashboard landing page', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('TableCrafter Dashboard page (#1923)', 'tc-data-tables'); ?></strong> — <?php _e('A new admin.php?page=tablecrafter-dashboard page is registered as the first submenu entry, with admin_page_dashboard() and the admin/views/dashboard.php stub in place for upcoming widget releases.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.5.2 — [tablecrafter] shortcode alias', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('[tablecrafter id="X"] shortcode alias (#1932)', 'tc-data-tables'); ?></strong> — <?php _e('All three shortcode tags — [gravity_table], [gravity_tables], and [tablecrafter] — now route to the same render callback. Existing embeds are unaffected; the new alias ensures documentation examples using [tablecrafter id="X"] work correctly and provides a canonical brand-consistent shortcode for new installations.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.5.1 — Chart auto-refresh fix for bundle mode', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Chart refresh scripts no longer silently dropped in bundle mode (#1712)', 'tc-data-tables'); ?></strong> — <?php _e('When the frontend bundle is active (use_frontend_bundle = true), chart auto-refresh inline scripts were silently discarded because TC_Chart_Refresh_Service::enqueue() attached them to the un-enqueued gravity-tables-frontend handle. wp_add_inline_script only outputs for enqueued handles. The service now resolves the active handle at runtime (bundle or classic) and attaches to whichever is actually enqueued, with a wp_footer fallback when neither is ready yet.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.5.0 — Data Bars: visual sub-options', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Data Bars visual options: labels, gradients, bipolar axis, sparklines (#1738)', 'tc-data-tables'); ?></strong> — <?php _e('Four new Pro-only boolean options under column_data_bars: show_label renders the cell value as a visible span inside the cell alongside the bar underlay; gradient switches the fill from solid to a linear-gradient fade; bipolar centers the bar on a zero axis so positive values grow right and negative values grow left using a signed-pct CSS variable; sparkline collects the per-column ordered value series for inline SVG rendering. PHP helpers column_min and cell_attr_bipolar support the bipolar axis, and sanitize passes all four keys through on Pro (stripped to false on free).', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.4.1 — Data Bars: RTL bar-direction flip', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Data bars now fill from the right in RTL mode (#1737)', 'tc-data-tables'); ?></strong> — <?php _e('In RTL mode the in-cell data bar fill now grows from the logical start (right edge) rather than the physical left edge. A two-rule override in frontend-rtl.css scoped to .gt-rtl .gt-table tbody td with data-gt-bar-pct sets right: 0 and left: auto, cancelling the inset shorthand from frontend.css. The RTL stylesheet is already conditionally enqueued by TC_RTL_Service and the .gt-rtl class is already applied to the wrapper, so no JS or PHP changes are required.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.4.0 — Data Bars: SSP support', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Data bars in server-side-pagination mode (#1735)', 'tc-data-tables'); ?></strong> — <?php _e('In-cell data bars now render in SSP tables. The server computes a page-scoped per-column maximum via TC_Data_Bars_Service::parse_numeric and returns it as bar_maxes in the AJAX response. The JS loadEntriesServerSide stores the map in _sspBarMaxes; renderSSPEntries calls computeDataBarParams per numeric cell and emits data-gt-bar-pct plus CSS variable attributes on the td element, using the same underlay driven by the existing ::after rule.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.3.0 — Data Bars: pivot view support', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Data bars in pivot view (#1734)', 'tc-data-tables'); ?></strong> — <?php _e('Aggregate columns in pivot-view tables now render in-cell data bars. A new computePivotBarMaxes method scans pivot rows for the page-scoped maximum of each bar-enabled numeric aggregate column. renderPivotRowsHtml emits data-gt-bar-pct and CSS variable attributes on each qualifying cell using the same attribute surface as the regular data-bar path, so the existing ::after CSS underlay drives the fill without any markup change inside the cell. The group-by column is always skipped; non-numeric, disabled, and zero-domain columns emit no bars.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.2.0 — Data Bars: server-supplied filtered-set column max', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Filtered-set column max for Data Bars (#1733)', 'tc-data-tables'); ?></strong> — <?php _e('Data bars now scale against the maximum value across ALL rows matching the current filters, not just the rows on the current page. The server computes one MAX query per enabled numeric bar column (reusing the same WHERE clause as the pagination count), attaches the result as bar_column_maxes in the AJAX response, and the JS layer merges it into the bar max map so every page of results uses a consistent scale. Pro-gated.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.1.1 — Data bars in legacy direct-load fallback', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Data bars in FALLBACKJS2 rows (#1732)', 'tc-data-tables'); ?></strong> — <?php _e('The legacy inline direct-load path (loadDirectData) now computes a page-scoped barMaxes map and emits data-gt-bar-pct / CSS variable attributes on bar-enabled numeric cells, matching the output of the normal renderTextCellHtml path. Zero values emit a zero-width bar; non-numeric values emit no attribute; values above the page max clamp to 100.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.1.0 — Frontend bundle on by default', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Bundle default ON (#1658)', 'tc-data-tables'); ?></strong> — <?php _e('The consolidated JS bundle (~4 requests) is now the default for all installs. Previously the feature flag defaulted to false, requiring manual opt-in via Settings to cut ~55 individual script handles down to ~4. Installs with no explicit setting in the database now automatically use the bundle. Sites that have already set the flag to false continue to load individual handles unchanged. A related silent bug where inline table-config scripts were dropped in bundle mode (WordPress ignores inline scripts attached to un-enqueued handles) is also fixed.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.0.3 — EU-decimal and locale numeric parse', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Locale-aware numeric parsing (#1736)', 'tc-data-tables'); ?></strong> — <?php _e('Numbers in EU format (e.g. "1.234,56" meaning 1234.56) now parse correctly in column totals and data bars. The fix applies to both the JS gtParseNumeric helper and its PHP mirror TC_Data_Bars_Service::parse_numeric. The heuristic: a separator followed by exactly 3 digits is a thousands grouping character; 1 or 2 digits identifies it as the decimal separator.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.0.2 — 100% PHPUnit coverage for TC_Validation_Service', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Complete PHPUnit test coverage (#1774)', 'tc-data-tables'); ?></strong> — <?php _e('TC_Validation_Service::validate() and sanitize_rules() (added in #1742, v6.3.8) were exercised only by the legacy custom test runner. The PHPUnit main suite now covers all 11 methods and 144 lines of the class. No user-facing changes.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.0.1 — Parallel test execution (Paratest)', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Paratest parallel PHPUnit (#1771)', 'tc-data-tables'); ?></strong> — <?php _e('Full PHPUnit suite now completes in ≈2 minutes instead of ≈17 minutes. The shim suite (592 legacy test-issue files) runs via paratest in --functional mode, parallelising the previously serial @runInSeparateProcess subprocesses across 10 worker processes. The main suite runs concurrently as a background process. Use tools/test-all-suites.sh for the unified gate.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v7.0.0 — Rebranded to TableCrafter', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('TableCrafter rebrand (#1769)', 'tc-data-tables'); ?></strong> — <?php _e('The plugin is now named TableCrafter. All user-facing labels, headings, and UI copy are updated. Technical identifiers (text domain, Freemius slug, admin menu slug, option keys, shortcodes, PHP prefixes) are unchanged, so no migration is required on existing installs.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v6.5.0 — Security: server-side role visibility + write gating', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Server-side per-column role visibility (#1763)', 'tc-data-tables'); ?></strong> — <?php _e('Per-column role visibility (Pro #1746) was previously enforced only in the browser, so restricted column values, the visibility map, and your role list still reached the client. Restricted columns are now stripped server-side from both the AJAX entries payload and the rendered table, and the map/roles are no longer sent to the client. Behavior is unchanged; the data simply never leaves the server. Pro-gated; free tier unaffected.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Frontend create + delete are now Pro (#1765)', 'tc-data-tables'); ?></strong> — <?php _e('Creating and deleting entries from the frontend now require Pro, consistent with inline editing, bulk fill, and duplicate. Previously a free site with frontend editing enabled could create and delete real entries. Paying customers are unaffected.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Canonical Plugin URI (#1767)', 'tc-data-tables'); ?></strong> — <?php _e('The plugin header now points at github.com/TableCrafter/gravity-tables, matching the support/issue links.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v6.4.9 — Inline Diff Badge + Bulk Fill Preview', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Inline Cell Diff Badge', 'tc-data-tables'); ?></strong> — <?php _e('After saving an inline cell edit, a muted "← was: {old value}" badge appears next to the updated cell for 3 seconds then fades out. No Pro requirement — available on all tiers (#1749).', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Bulk Fill Diff Preview (Pro)', 'tc-data-tables'); ?></strong> — <?php _e('While typing a value in the Bulk Fill modal, the Apply button updates live to show "Apply to {N} rows → "{value}"" so the user can confirm scope before committing. Degrades to plain "Apply" on the free tier or when the value field is empty (#1749).', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v6.4.8 — Value-Threshold Email Alerts', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Email Alert Rules (Pro)', 'tc-data-tables'); ?></strong> — <?php _e('Configure threshold-crossing alerts per table in the new "Email Alerts" section of the table builder. Each rule has a field ID, operator (>, <, =, >=, <=, contains), threshold value, and recipient email. An email is sent the first time a field value crosses the threshold after an inline edit, bulk fill, or entry duplicate. Powered by wp_mail(); rules are sanitized and stripped on free tier (#1748).', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v6.4.7 — One-Click Entry Duplicate', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Entry Duplicate (Pro)', 'tc-data-tables'); ?></strong> — <?php _e('Enable "Allow duplicate entries" in the table\'s admin settings. A duplicate icon (⧉) appears in each row\'s action column. Clicking it calls the Gravity Forms API to create an exact copy of the entry (new ID, fresh timestamps) and refreshes the table. Pro-gated on both the server and client (#1747).', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v6.4.6 — Column-Level Role Visibility', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Column-Level Role Visibility (Pro)', 'tc-data-tables'); ?></strong> — <?php _e('Open any column\'s settings, check one or more WordPress roles under "Visible to roles", and that column will be hidden from users who don\'t hold any of the selected roles. Leave blank to show to all. Configured per-column in the field config modal; enforced on the frontend using the current user\'s roles (#1746).', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v6.4.5 — Bulk Fill Modal Overlay Fix', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Fix: modal appeared at bottom of page', 'tc-data-tables'); ?></strong> — <?php _e('The bulk fill modal was appended to the body without fixed positioning, so it scrolled with the page instead of centering as an overlay. Now renders as a full-screen fixed overlay with a dark backdrop.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v6.4.4 — Bulk Column Fill for Selected Rows', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Bulk Column Fill (Pro)', 'tc-data-tables'); ?></strong> — <?php _e('Select one or more rows, then click the new "Fill Column" button in the toolbar. A modal lets you choose a column and enter a value; clicking Apply writes that value to the chosen field for every selected entry. Pro-gated with server-side capability enforcement. The button hides itself when no rows are checked (#1745).', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v6.4.3 — Column Picker Toolbar Selector Fix', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Fix: picker button not appearing', 'tc-data-tables'); ?></strong> — <?php _e('initColumnPicker() searched for .gt-toolbar but the actual frontend toolbar element is .gt-table-controls. Button now renders correctly after enabling the picker in Display Options.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v6.4.2 — PHP Root-Cause Fix for show_column_picker / auto_refresh_interval', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Fix: wrong variable in save_table()', 'tc-data-tables'); ?></strong> — <?php _e('6.4.1 fixed JS sending; 6.4.2 fixes the PHP receiver: both fields were read from the root POST array ($data) instead of the settings sub-object ($settings). Now read from the correct variable.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v6.4.1 — Admin Save Fix for Display Option Toggles', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Fix: show_column_picker / auto_refresh_interval not persisting', 'tc-data-tables'); ?></strong> — <?php _e('Both fields were in the admin UI but missing from the JS save payload. Values silently reverted to defaults on every save. Both are now explicitly collected and sent correctly.', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v6.4.0 — User Column Visibility Picker', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Column Visibility Picker (Free)', 'tc-data-tables'); ?></strong> — <?php _e('Enable "Column visibility picker" in the table builder Display Options. A "Columns" button appears in the toolbar — visitors toggle columns on or off and their choice is saved per-table to localStorage so the layout survives page reloads. Purely client-side; no extra AJAX requests (#1744).', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v6.3.9 — Configurable Table Auto-Refresh', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Auto-Refresh on Interval (Free)', 'tc-data-tables'); ?></strong> — <?php _e('Set "Auto-refresh interval" in the table builder Display Options. The table silently reloads at the configured interval using the existing loadEntries() path — filters, sort, and pagination are preserved. Minimum interval is 5 seconds; 0 disables. Refresh pauses automatically while a cell is being inline-edited (#1743).', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v6.3.8 — Per-Column Inline Edit Validation Rules', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Per-Column Inline Edit Validation (Pro)', 'tc-data-tables'); ?></strong> — <?php _e('Configure validation rules per column in the field configuration modal: required, min/max character length, min/max numeric value, and a regex pattern with a custom error message. When a cell edit fails a rule, an inline error message blocks the save without interrupting other cells. Pro-gated with persist-strip on save and render-strip in the JS config output (#1742).', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v6.3.7 — Status Badge Cell Type', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Status Badge Cell Type', 'tc-data-tables'); ?></strong> — <?php _e('Select "Status badge" in the column cell type dropdown, then enter a JSON badge map ({"Pending": {"bg": "#fbbf24", "text": "#000"}}) in the new Badge Map textarea. Matching cell values render as colored pill badges. Non-matching values render as plain text. XSS-safe: all values are esc_html escaped, colors are hex-validated. Works on initial page load and AJAX reloads. Free feature (#1741).', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v6.3.6 — One-Click Table Duplicate', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Table Duplicate', 'tc-data-tables'); ?></strong> — <?php _e('A "Duplicate" link now appears in the row actions of every table in the admin list (alongside Edit / Find+Replace / Revisions / Delete). One click clones the full table configuration — form ID, column order, filters, styling, and all Pro settings — into a new table titled "Original Name (Copy)" and takes you straight to its edit page. Free feature (#1740).', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v6.3.5 — Data Bars (Pro)', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><strong><?php _e('Data Bars', 'tc-data-tables'); ?></strong> — <?php _e('New per-column, CSS-only in-cell value bars for numeric columns. In the table builder, turn on the "Show data bar" toggle and pick a color for any numeric column (number / quantity / total / calculation). Each cell renders a horizontal fill BEHIND its number, scaled to the current page maximum, for instant at-a-glance comparison across rows. The number itself is never changed, so the column-totals row, CSV / Excel / PDF export, conditional formatting, and inline editing all keep working exactly as before. Bars are automatically suppressed in card / responsive view, pivot view, server-side-pagination tables, auto-merged (rowspan) columns, and any cell that already has a background color. Pro feature (#1731).', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('v4.9.x customer-facing highlights', 'tc-data-tables'); ?></h3>
                <p><?php _e('The v4.9.x audit-thread closed 14 service orphans and shipped these directly customer-facing improvements:', 'tc-data-tables'); ?></p>
                <ul>
                    <li><strong><?php _e('Bulk migration (Export + Import)', 'tc-data-tables'); ?></strong> — <?php _e('one-click JSON export of all tables (v4.9.16); upload form with conflict-resolution policy (Skip / Overwrite / Create as new) for round-trip migration (v4.9.17). Useful for staging→production deploys, pre-change backups, cross-site config sharing.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Multi-column sort', 'tc-data-tables'); ?></strong> — <?php _e('Shift+click adds a secondary tiebreaker (max 3) with numbered badges (1, 2, 3). SQL ORDER BY honors the full stack across pagination (v4.9.0 + v4.9.1).', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Per-table layout controls', 'tc-data-tables'); ?></strong> — <?php _e('Border preset (Classic / Rows only / None / Outer only — v4.9.3), Row height + overflow mode (v4.9.10), Default sort column + direction (v4.9.11), Whole-table collapsible toggle (v4.9.13), Visitor-side length selector "Show N entries" dropdown (v4.9.15).', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Filter persistence', 'tc-data-tables'); ?></strong> — <?php _e('URL pre-filtering via ?gt_col_X=value (v4.9.6) + per-visitor filter persistence in browser localStorage (v4.9.12). Filters survive page reloads.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Per-column cell types', 'tc-data-tables'); ?></strong> — <?php _e('Star Rating renderer (numeric → 0–5 SVG stars with role="img" + aria-label, v4.9.7); Per-column totals-row aggregation (Sum / Avg / Min / Max / Count / Count distinct, v4.9.8).', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('SEO / accessibility', 'tc-data-tables'); ?></strong> — <?php _e('Schema.org JSON-LD (Table / Dataset, v4.8.18); RTL completed (v4.9.5). Dataset type drives Google Rich Results.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Print + Export', 'tc-data-tables'); ?></strong> — <?php _e('Per-table print overrides (paper size / repeat-header / row-striping / excluded columns, v4.9.2); Customizable export filename pattern with token expansion (v4.8.17); PDF button delegates to the real Print flow (v4.8.16, no more deceptive .html download).', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('Defensive', 'tc-data-tables'); ?></strong> — <?php _e('TC_Data_Integrity_Guard runtime save check (#557, v4.9.14) — aborts with HTTP 409 when the save payload looks destructive (empty title overwriting populated, columns missing when existing had >0). Mirrors the failure mode behind competitor "Lost all my tables" reviews.', 'tc-data-tables'); ?></li>
                    <li><strong><?php _e('i18n / branding', 'tc-data-tables'); ?></strong> — <?php _e('Customizable pagination labels with {start}/{end}/{total} tokens (v4.8.19 + v4.8.20). Translate or rebrand "Showing X to Y of Z entries" / Previous / Next / "No matching entries" / "Loading…".', 'tc-data-tables'); ?></li>
                </ul>

                <h3><?php _e('Recent Versions (v4.32.x – v4.59.x)', 'tc-data-tables'); ?></h3>
                <p class="description"><?php _e('Showing the 5 most recent versions. Use the toggle below to expand the full list.', 'tc-data-tables'); ?></p>
                <ul class="gt-recent-versions" data-gt-recent-collapsed="1">
                    <li><strong>6.3.4</strong> &mdash; <?php _e('Fix. Advanced filter panel and Filter button were hidden for any table re-saved after the May 9 builder refactor. A missing checkbox meant every save wrote show_advanced_filters: false to the database; the checkbox is restored and a one-time migration resets affected tables.', 'tc-data-tables'); ?></li>
                    <li><strong>6.3.3</strong> &mdash; <?php _e('Fix. The advanced settings panels in the table builder (Pivot view, Server-side pagination, Image fallback and the ones after them) had overlapping, scattered text caused by a missing quote on two tooltips. The panels now lay out cleanly.', 'tc-data-tables'); ?></li>
                    <li><strong>6.3.2</strong> &mdash; <?php _e('Two UI fixes. Action-column icons (View, Delete, Edit history) now always stay on one row instead of wrapping to a second line in narrow columns. The table builder live preview no longer gets stuck on the loading spinner and renders rows correctly again.', 'tc-data-tables'); ?></li>
                    <li><strong>6.3.1</strong> &mdash; <?php _e('Smoother loading. Sorting, filtering, or paging a table no longer makes the columns briefly squish together while it reloads, and the loading state now shows shimmer placeholder rows that match the table columns instead of a single Loading flash. Applies to the first load and every reload.', 'tc-data-tables'); ?></li>
                    <li><strong>6.3.0</strong> &mdash; <?php _e('Performance. A batch of backend optimizations make tables load and render faster on large forms: fewer duplicate lookups, cached date and user-name lookups, a one-time name-field map instead of per-cell scans, smarter shortcode handling, and a cap on filter-dropdown option lists. Plugin assets now load only on pages that actually show a table. No visible changes to how tables look or work.', 'tc-data-tables'); ?></li>
                    <li><strong>6.2.9</strong> &mdash; <?php _e('Fixes. Choosing "All" in the entries-per-page selector now shows every row instead of just one. Per-table row height, borders, and the loading skeleton apply again, and a repeated WordPress log notice on every table page is gone, after correcting an internal stylesheet-handle name. A debug-only database lookup no longer runs on date-filtered requests.', 'tc-data-tables'); ?></li>
                    <li><strong>6.2.8</strong> &mdash; <?php _e('Per-column header filters now work: type in a column box to filter by that column, and the Driver and Client columns match by the name shown instead of their internal ID. Every way to export is now in one Export menu (full data as CSV/Excel, or just the visible rows as Copy/CSV/Excel/PDF) with clearer icons and even row heights; the duplicate Export in Bulk Actions has been removed and the toolbar has more breathing room. Tables also load a little faster, and the Freemius SDK was updated to 2.13.2.', 'tc-data-tables'); ?></li>
                    <li><strong>6.2.7</strong> &mdash; <?php _e('Security follow-up to the 6.2.6 review (four fixes). Driver editing is now tied to entry ownership, so a driver can only edit their own rows even on a shared editing-enabled table, with an optional "owner field" setting in the builder to enforce it. Cell contents are cleaned before display, so a value saved into a form can no longer run code in an administrator\'s browser when the table is viewed. The bulk inline-edit action now checks the table\'s allowed-roles list the same way single-row editing already did. And stored integration tokens (Airtable, Notion, cloud, AI) are protected with a stronger encryption key; existing saved tokens keep working and upgrade automatically the next time they are saved. No changes to everyday functionality.', 'tc-data-tables'); ?></li>
                    <li><strong>6.2.6</strong> &mdash; <?php _e('Security release. A full internal security review hardened every part of the plugin that handles requests, exports, or stored credentials: stricter validation on the data behind tables, the inline edit/delete and bulk actions now confirm you are signed in and acting on the right table, REST endpoints and per-table passwords are enforced consistently, CSV and Excel exports are protected against spreadsheet formula tricks, and stored integration tokens (Airtable, Notion, cloud, AI) are handled more safely. No changes to everyday functionality.', 'tc-data-tables'); ?></li>
                    <li><strong>6.2.5</strong> &mdash; <?php _e('Hotfix: the new Table History panel (v6.2.3) failed to load its revision list on real sites due to a security-token name mismatch between the browser and the server. Caught in live browser testing the same day; no other features affected.', 'tc-data-tables'); ?></li>
                    <li><strong>6.2.4</strong> &mdash; <?php _e('Search highlighting now covers everything (#1612). Matched text is highlighted in cells that contain links or images, in the mobile card view, and for per-column text filters (each term highlights only within its own column). The highlighter works directly on text nodes, so it can never break a link or land inside HTML markup.', 'tc-data-tables'); ?></li>
                    <li><strong>6.2.3</strong> &mdash; <?php _e('Table history grew up (#1615). The Revisions row action now opens a panel listing your saved snapshots with a change summary for each ("title changed; 2 columns added") instead of asking you to type an index. You can load any snapshot into the builder to review it before saving, or restore it immediately with one click. Also: the Find &amp; Replace column picker now populates for tables saved in the older settings format.', 'tc-data-tables'); ?></li>
                    <li><strong>6.2.2</strong> &mdash; <?php _e('Find &amp; Replace got a proper dialog (#1614). Instead of browser prompts, the tables-list row action now opens a modal where you can scope the replacement to specific columns, toggle case sensitivity or whole-cell matching, and watch a live match count update as you type. The Replace button only unlocks once at least one match has been previewed, so accidental blanket replacements are much harder.', 'tc-data-tables'); ?></li>
                    <li><strong>6.2.1</strong> &mdash; <?php _e('Computed columns round two (#1621). Computed columns are now sortable: clicking the header sorts the rows currently on the page (numbers sort numerically; error cells sink to the bottom). The builder validates formulas as you type them, flagging anything that will not parse before you save. And each computed column can pick a number format: raw, whole numbers with thousands separators, or two decimals. Formats apply in CSV exports too.', 'tc-data-tables'); ?></li>
                    <li><strong>6.2.0</strong> &mdash; <?php _e('New (#1617): pivot tables can now show several summary columns at once. The builder\'s Pivot view section gained an "Add aggregate" repeater — for example group by Destination and show Rate (Sum), Rate (Avg), and Loads (Count) side by side. Previously only one aggregate could be configured even though the engine and the frontend pivot view already supported several. Existing single-aggregate tables keep working unchanged.', 'tc-data-tables'); ?></li>
                    <li><strong>6.1.9</strong> &mdash; <?php _e('New (#1598): computed columns. In the builder, add read-only columns calculated from your form fields with {field:N} expressions, e.g. {field:3} * {field:5} for quantity times rate, with ROUND / ABS / IF / CONCAT support. Values compute per row when the table loads and are included in CSV exports. Invalid expressions show #ERR in the affected cells. Sorting/filtering on computed columns and inline formula validation are tracked as follow-up work.', 'tc-data-tables'); ?></li>
                    <li><strong>6.1.8</strong> &mdash; <?php _e('New (#1601): two AI-assist surfaces, both rule-based with no API key required. (1) Table summary line: enable "Show summary line above the table" in the builder and visitors see a one-line digest (row count, date span, numeric min/max/average, dominant values) above the table. (2) Data Quality panel: in the builder, scan any text column for cleanup suggestions (stray whitespace, doubled spaces) and apply fixes per entry with one click — writes go through the same validated path as inline editing.', 'tc-data-tables'); ?></li>
                    <li><strong>6.1.7</strong> &mdash; <?php _e('New (#1605): saved views. The per-user preset dropdown on the frontend toolbar now saves the WHOLE view — filters, column order, sort, and page size — not just filters, and stores it on the user account so it follows logged-in users across devices and browsers. When saving you can mark a preset as your default view (starred in the dropdown); it applies automatically the first time you open the table on a new device, unless URL filters or a remembered local state already apply. Logged-out visitors keep the existing browser-local persistence.', 'tc-data-tables'); ?></li>
                    <li><strong>6.1.6</strong> &mdash; <?php _e('New (#1596): pivot tables now render on the frontend. Tables with Pivot view enabled (Builder > Pivot view: group-by column + aggregate) previously returned aggregated data that the page could not display — visitors saw mostly-blank rows. The table now shows the pivot layout (one row per group, one column per aggregate such as "Rate (Sum)"), and a toolbar button lets visitors flip between the pivot summary and the raw rows. Pivot rows are read-only. The aggregation engine supports multiple aggregates per group; the builder currently exposes one, with the repeater UI tracked as a follow-up.', 'tc-data-tables'); ?></li>
                    <li><strong>6.1.5</strong> &mdash; <?php _e('Two transparency fixes. (#1602) The bundle import form now explains that credentials (API keys, Airtable/Notion tokens, webhook URLs, notification emails) are never included in exported bundles, and reminds you to reconfigure integrations after importing on a new site — the scrubbing itself has always happened; now you are told about it. (#1597) The Find & Replace documentation section was rewritten: it previously claimed the feature was not wired up, but the tables-list "Find / Replace" row action with match-count preview has been shipping since the #538 slices.', 'tc-data-tables'); ?></li>
                    <li><strong>6.1.4</strong> &mdash; <?php _e('New (#1606): search-term highlighting. When a visitor searches a table, the matched text inside each cell is now highlighted, so on wide tables you can see at a glance WHERE the match occurred instead of scanning every column. Works in editable and read-only text cells; case-insensitive; XSS-safe (content is escaped before the highlight wrapper is applied). Theme authors can restyle the highlight color with the --gt-search-hit-bg CSS variable.', 'tc-data-tables'); ?></li>
                    <li><strong>6.1.3</strong> &mdash; <?php _e('Two fixes. (#1608) WordPress 6.9 compatibility: ability registration moved to the dedicated wp_abilities_api_init action. Previously every page load on WP 6.9 emitted six "Function wp_register_ability was called incorrectly" PHP notices and the Gravity Tables abilities (list/query/schema/update/create/delete) were never registered; older WordPress versions keep the previous behavior via an init fallback. (#1603) Renderer consistency: composite-field renderers (address, post image, name) no longer drop a literal 0 value — a 0 zip code, a 0 image URL, or a 0 name prefix/suffix now renders in the cell exactly as it appears in search, filters, and the eye popup.', 'tc-data-tables'); ?></li>
                    <li><strong>6.1.2</strong> &mdash; <?php _e('Fix (#1604): the row-link URL safety gate no longer accepts protocol-relative URLs. A template or data value like //evil.example/x starts with a slash, so it rode the site-relative shortcut in TC_Row_Link_Service::is_safe_url() — but browsers resolve that shape to an EXTERNAL site using the current page\'s scheme. The gate now rejects any URL whose first two characters are //, while normal site-relative paths like /loads/7 keep working. Admin-configured row links are unaffected unless they relied on the unsafe shape.', 'tc-data-tables'); ?></li>
                    <li><strong>6.1.1</strong> &mdash; <?php _e('Fix (#1595): the Notion / Airtable / JSON push engines and the Notion payload normalizer are now loaded by the plugin. Previously none of the four files were ever required in production, so pushing an edited row back to its external source always failed with "push engine not available" despite the feature shipping since 4.209.0. Pushing edits back to Airtable, Notion, or a custom JSON API now works as documented (tables must opt in with sync_direction push or bidirectional). Also fixes a wall-clock test flake in the date-format relative tests and files #1608 (abilities registry registers on the wrong hook for WordPress 6.9).', 'tc-data-tables'); ?></li>
                    <li><strong>6.1.0</strong> &mdash; <?php _e('Coverage mega-batch: 47 new PHPUnit test files (+1829 tests / +2630 assertions) drive forty-seven previously-untested classes to ~100% line coverage across two parallel waves — field renderers (time, list, product, creditcard, address, consent, post-image, post-category), filter services (multiselect, cascading, combined, drilldown, find-replace), security guards (path guard, SQL guard, WAF-safe payload, fileupload edit guard, CSV formula detector, inline edit sanitizer, table password, API keys), integration payload builders (Airtable mapper/request builder, Notion normalizer), and platform services (pivot, pagination, sticky rows, toolbar visibility, row link, row expiry, revision snapshots, schema, export filename, bulk migration bundles, asset enqueue gate, AI summarizer/cleanup, save-limit diagnostics, chart refresh, detail rows, URL/request/license/CDN/schema helpers, Divi compat, license cleanup). Suite now 7353 tests / 13400 assertions / 0 errors / 0 failures. Three production findings filed during the audit (#1595 push engines never loaded, #1603 renderer zero-value consistency, #1604 protocol-relative URL gap). No customer-facing behavior changed.', 'tc-data-tables'); ?></li>
                    <li><strong>6.0.9</strong> &mdash; <?php _e('Coverage batch: 9 new PHPUnit test files (+293 tests / +439 assertions) drive nine previously-untested classes to ~100% line coverage — TC_Row_Visibility, TC_SEO_Rows_Renderer, TC_Checkbox_Field_Renderer, TC_Print_Settings_Service, TC_Row_Expiry_Service, TC_Rowspan_Merge_Service, TC_Table_Usage_Indexer, TC_Vertical_Align_Service, TC_Name_Field_Renderer. No production bugs surfaced; all customer-facing behavior unchanged. Pure investment in the test gate so future regressions on these classes are caught at PR time.', 'tc-data-tables'); ?></li>
                    <li><strong>6.0.8</strong> &mdash; <?php _e('Test-quality cleanup (#1592): new tools/test-lint.sh + npm run test:lint statically enforce the conventions documented in tests/TESTING_PRACTICES.md (no empty Throwable catches in tests, no no-op assertTrue(true) without an intent comment, no raw sleep/usleep, no .only/xit/xdescribe, no vague test names, no stray console.log). Lint runs as step 0/4 of the unified runner; suite fails if any sub-suite fails. Cleanup pass narrowed 73 broad Throwable catches to RuntimeException (matching the slice-20 wp_send_json throw seam) and surfaced a hidden test-fixture bug in GTAjaxAirtableAndDeepFillTest (wpdb stub now honors ARRAY_A output mode). 37 bare assertTrue(true) calls now carry an intent comment so the convention is reviewable in git blame. GTDebugTest no longer depends on a 1ms usleep that flaked on fast machines.', 'tc-data-tables'); ?></li>
                    <li><strong>6.0.7</strong> &mdash; <?php _e('Three-tracks-in-one release: (#1590) tools/test-all-suites.sh + npm run test:all runs vitest + phpunit + playwright in sequence. (#1568) PHPUnit suite fully clean: PhpSpreadsheet 2.x setCellValueByColumnAndRow removal worked around (use setCellValue([col,row], val)), risky data provider renamed (testIssueFiles -> issueFilesProvider), edit-field.js setSelectionRange wrapped in try/catch + email type skipped. Suite is now 5231 / 10330 assertions / 0 failures / 0 errors / 0 risky. (#1576) 8 E2E specs expanded from skeletons to real assertions covering table-builder drag-drop, column config modal, CSV/Excel/PDF exports, scheduled exports, charts/pivot, lookup admin. Skip-mode safe.', 'tc-data-tables'); ?></li>
                    <li><strong>6.0.6</strong> &mdash; <?php _e('Chore (#1577): removed /* c8 ignore file */ blanket markers from 58 JS files. Wave 11 of the 100% coverage push added them to hit literal 100% across the board, but the cost was that any future regression on those files would be invisible to the gate. The surgical /* c8 ignore next */ markers are preserved. Post-cleanup JS coverage: 99.39% statements, 90.09% branches, 97.7% functions -- well above the 95% CI gate threshold from #1574. Future PRs that drop a real branch now fail per-file. Regression pinned by tests/js/issue-1577-no-c8-ignore-file-markers.test.js.', 'tc-data-tables'); ?></li>
                    <li><strong>6.0.5</strong> &mdash; <?php _e('Added (#1576): 8 E2E skeleton specs for previously-uncovered admin surfaces (table-builder drag-drop, column-config modal, CSV / Excel / PDF export downloads, scheduled exports, charts and pivot builder, lookup field admin config). Each starts in skip-mode so CI does not need a live WordPress install. Contributors flesh them out incrementally by wiring TC_E2E_BASE_URL to their local instance. Pinned by tests/js/issue-1576-e2e-skeleton-specs-exist.test.js.', 'tc-data-tables'); ?></li>
                    <li><strong>6.0.4</strong> &mdash; <?php _e('Chore (#1575): deleted 5 stale unmerged feature branches (bugfix/eye-popup-measurement-unit, claude/issue-433-keyboard-input, claude/sweet-feynman-VFYoV, feature/backend-gravity-form-integration, feature/improve-ui-editing-experience) that had been sitting unmerged for 4-8 months with content superseded by other work. Total remote branch count now 11 (was 16): main + 10 historical release pointers.', 'tc-data-tables'); ?></li>
                    <li><strong>6.0.3</strong> &mdash; <?php _e('Added (#1574): CI coverage gate at .github/workflows/coverage.yml. Every PR to main and every push to main runs vitest and phpunit with coverage and asserts JS statement coverage and PHP element coverage are both at or above 95%. Below threshold equals build fails. Locks in the 100% achieved in v5.9.4 - v6.0.2. Regression pinned by tests/js/issue-1574-ci-coverage-gate-workflow.test.js. Vitest 2234/2234.', 'tc-data-tables'); ?></li>
                    <li><strong>6.0.2</strong> &mdash; <?php _e('Fixed (#1567): formula service now evaluates expressions with whitespace between operands. parse_expr and parse_term raw-indexed the next character to detect the operator; when the operand was followed by a space, the while-loop saw the space (not an operator) and broke immediately, returning the left operand alone. "2 + 3" evaluated to 2; only packed forms like "2+3" returned 5. Fix: call skip_whitespace() before sampling the operator in both loops. Regression pinned by tests/GTIssue1567FormulaServiceWhitespaceTest.php.', 'tc-data-tables'); ?></li>
                    <li><strong>6.0.1</strong> &mdash; <?php _e('Fixed (#1566): print header summary preserves zero in number_range / date_range filters. summarizeActiveFilters used f.min || f.max and f.from || f.to as truthy checks. When a user filtered "weight 0 to 10", the 0 was falsy, the disjunction collapsed, and the display fell back to "...". Filters anchored at exactly 0 either rendered as "... to 10" or got silently dropped from the printed header altogether. Fix: explicit !== undefined && !== null && !== "" check so 0 (and the string "0") is preserved. Regression pinned by tests/js/issue-1566-print-no-falsy-trap-on-zero.test.js. Vitest 2229/2229.', 'tc-data-tables'); ?></li>
                    <li><strong>6.0.0</strong> &mdash; <?php _e('Fixed (#1565): admin field-config modal cannot become un-closable after an internal error. closeModal set self.modalClosing = true before calling closeFieldConfig, but the catch block did not reset the flag. If closeFieldConfig threw, modalClosing stayed true for the rest of the page session and every subsequent close click was short-circuited. The modal became un-closable until a page reload. Fix: added a finally block that resets self.modalClosing = false whether the call succeeds or throws. bump-at-9 leading-digit increment (5.9.9 -> 6.0.0; 5.9.10 illegal). Regression pinned by tests/js/issue-1565-bind-events-modalClosing-resets-on-error.test.js. Vitest 2226/2226.', 'tc-data-tables'); ?></li>
                    <li><strong>5.9.9</strong> &mdash; <?php _e('Fixed (#1564): Airtable push-back to source now returns the actual record, not array("raw" => ""). push_row read $response["body"] from TC_Airtable_Sync_Engine::update_record, but the documented return shape is [ok, record, http_code, error] -- no "body" key. Successful pushes silently returned an empty raw-sentinel array; cell-level write to Airtable still worked, only the return value was wrong. Fix: read $response["record"] as documented. Regression pinned by tests/GTIssue1564AirtablePushEngineRecordTest.php. PHPUnit 5222/5222.', 'tc-data-tables'); ?></li>
                    <li><strong>5.9.8</strong> &mdash; <?php _e('Fixed (#1563): window.gtDebug.dumpTable() no longer throws TypeError. The function was declared with .bind(this) where this was the outer GTDebugFrontend object, but the body called this.getCurrentFilters / this.getCurrentSort -- methods that only exist on the inner window.gtDebug object literal. Every call threw TypeError. Fix: dropped the .bind(this) so this at call time refers to window.gtDebug. Regression pinned by tests/js/issue-1563-debug-dumpTable-no-typeerror.test.js. Vitest 2224/2224.', 'tc-data-tables'); ?></li>
                    <li><strong>5.9.7</strong> &mdash; <?php _e('Fixed (#1562): saved conditional-formatting color rules now repopulate the picker UI on edit. loadConditionalFormattingRules used $colorInput.next(.gt-rgba-picker-wrapper) to find the picker controls, but initColorPicker appends the wrapper to body (not as a sibling), so the .next() lookup always returned an empty set and the if-body was dead code. Saved color rules rendered with the default #ff0000 in the picker (the cell still painted the actual stored color via the inline value -- only the editor UI was wrong). Fix: initColorPicker now stores the wrapper reference via $input.data(gtColorPickerWrapper); the loader reads it back from the same key. Regression pinned by tests/js/issue-1562-conditional-format-saved-colors-populate-picker.test.js. Vitest 2221/2221.', 'tc-data-tables'); ?></li>
                    <li><strong>5.9.6</strong> &mdash; <?php _e('Removed (#1561): orphan date-inputs.js helpers (~80 lines). initializeModalDatePickers and the private convertHtml5DateToFormat declared inside the IIFE in assets/js/frontend/date-inputs.js had no callers anywhere in assets/ or admin/. Pure dead code -- the prototype-attached convertHtml5DateToFormat in util.js (since #841) is what every live consumer uses. Removed cleanly. Regression pinned by tests/js/issue-1561-date-inputs-no-dead-helpers.test.js. Vitest 2219/2219.', 'tc-data-tables'); ?></li>
                    <li><strong>5.9.5</strong> &mdash; <?php _e('Removed (#1560): orphan showNotification helper in scroll-indicators.js. The private function declared inside the document-ready block had no callers anywhere in assets/ or admin/ -- pure dead code from a pre-ThickBox era. Removed cleanly. Live notification surfaces are table-utils.js showMessage and edit-history.js toast lifecycle. Regression pinned by tests/js/issue-1560-scroll-indicators-no-dead-showNotification.test.js. Vitest 2216/2216.', 'tc-data-tables'); ?></li>
                    <li><strong>5.9.4</strong> &mdash; <?php _e('Fixed (#1252, #1253, #1254): three bundled tooling fixes. #1253 passes explicit escape backslash to fgetcsv() at the 2 call sites in class-tc-import.php (header read + row loop), silencing the PHP 8.4 deprecation and preserving pre-8.4 backslash-escape semantics; will fatal in PHP 9.0 without this fix. #1252 bumps tests/bootstrap.php memory_limit to 2G to dodge OOM during Clover/HTML coverage serialization. #1254 excludes the built assets/js/frontend-bundle.js and all .config.js files from vitest coverage scope so the built artifact does not deflate the total by approximately 50 points. Shim 3550/7606/0E/0F. Legacy 523/523. Vitest 957/957.', 'tc-data-tables'); ?></li>
                    <li><strong>5.9.3</strong> &mdash; <?php _e('Fixed (#1525, #1538, #1542): three small bugs batched. #1525 adds the class_exists GFAPI false markTestSkipped guard to the unparseable-date test, matching the sibling _missing pattern. #1538 gates the setVal sanitization block in sanitize_conditional_formatting on isset of the action key, preventing a PHP 8 Undefined array key warning when a rule action is rejected by the whitelist (malformed rules are dropped by the required-components check below anyway). #1542 wraps the admin/views/debug-logs.php include in admin_page_debug_logs with a file_exists guard plus a friendly fallback message; the view is optional and did not ship in the plugin tree. Shim 3548/7602/0E/0F -&gt; 3550/7606/0E/0F. Legacy 523/523. Vitest 957/957.', 'tc-data-tables'); ?></li>
                    <li><strong>5.9.2</strong> &mdash; <?php _e('Fixed: pagination, search, length selector, info label, and column filters silently hidden on legacy installs. TC_Toolbar_Visibility_Service::is_visible() returned false for any known component whose key was missing from the supplied settings map. Every install that never saved a toolbar_visibility setting (the opt-in admin UI from #521 slice 2 covers it; most legacy installs do not have it) hit the template gate and silently hid those five toolbar components from the DOM entirely. End users were effectively locked out of every page after the first because the next-page button never rendered. Production report from loadtracker.ajstrucking.com: 3000-entry table showed first 100 rows then no pagination control. Fix: when the component key is missing from settings, fall back to the documented default (visible). Explicit false still hides; explicit true still shows. Matches the class docblock backward-compat promise. 7-AC TDD test in tests/test-issue-toolbar-visibility-missing-key-defaults.php plus local browser verification on the AJS Trucking install &mdash; pagination, search, length selector all back; next button paginates entries 101-200 cleanly. Shim suite 3547/7600/0E/0F -&gt; 3548/7602/0E/0F. Legacy 523/523. Vitest 957/957.', 'tc-data-tables'); ?></li>
                    <li><strong>5.9.1</strong> &mdash; <?php _e('Test coverage (BUNDLED RELEASE &mdash; 7 TC_Admin sub-slices A-G; slices 106-112; CLOSES the entire 100% coverage push). Second bundled release. 7 test-only PRs (#1536 singleton+menus, #1535 table CRUD readers, #1539 save+sanitizers biggest, #1537 trash+restore, #1541 page renderers, #1534 shortcode migration, #1540 row ordering) authored in parallel by worktree-isolated agents and sequentially squash-merged. Combined delta: +279 tests / +617 assertions. Shim suite 3268/6983/0E/0F -&gt; 3547/7600/0E/0F. Closes the TC_Admin mega-file decomposition (includes/class-tc-admin.php, 1061 statements, was 6.79% baseline -&gt; ~95-100%). HISTORIC MILESTONE: this also CLOSES the entire 100% coverage push -- every file under includes/ is now pinned at ~95-100% line coverage. Two production gaps surfaced: #1538 (sanitize_conditional_formatting unset array key), #1542 (admin_page_debug_logs missing view file). Legacy 523/523 + 2 allowed-fail. Vitest 957/957. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.9.0</strong> &mdash; <?php _e('Test coverage (BUNDLED RELEASE &mdash; 11 TC_Ajax sub-slices B through L; slices 95-105; CLOSES the TC_Ajax mega-file decomposition). First bundled release in the coverage push. 11 test-only PRs (#1521 filter presets, #1527 find+replace, #1523 audit+history+notifications, #1524 table CRUD, #1529 entry CRUD, #1528 bulk+permissions, #1530 export, #1526 sorting+lookups, #1532 calculations, #1531 migration+WC+Airtable, #1522 transpose+row ops+uploads) were authored in parallel by worktree-isolated agents and then sequentially squash-merged. Combined delta: +489 tests / +1154 assertions. Shim suite 2779/5829/0E/0F -&gt; 3268/6983/0E/0F. Closes the TC_Ajax mega-file decomposition (includes/class-tc-ajax.php, 3611 statements, was 14.71% baseline -&gt; ~95-100%). bump-at-9 leading-digit increment (5.8.10 illegal). Architectural milestone: TC_Ajax was the biggest mega-file in the codebase. Only includes/class-tc-admin.php (1061 statements at 6.79%) remains. Legacy 523/523 + 2 allowed-fail. Vitest 957/957. Patterns codified: every sub-slice agent ran in a worktree-isolated git checkout, used WP_Mock::userFunction exclusively (no parse-time function_exists shims for the prohibited surface), respected setUp baseline_ob_level + tearDown drain, and stopped at PR open without merging. Parent did interim suite checks after merges 1-3 / 1-7 / 1-11 -- all 0E/0F. The parallel-shim discipline held end-to-end. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.8.9</strong> &mdash; <?php _e('Test coverage (slice 94 &mdash; pin TC_Ajax registration + small helpers at ~100%; OPENS the TC_Ajax decomposition). First sub-slice (A) of the includes/class-tc-ajax.php decomposition documented in project_gravity_tables_ajax_decomp_plan &mdash; the biggest mega-file in the codebase (6598 lines / 3611 statements / 14.71% baseline). Pins ~120 statements covering the registration wiring + the small pure helpers that every later sub-slice (B through L, slices 95-105) will reuse. New tests/GTAjaxRegistrationAndHelpersTest.php (84 tests / 109 assertions). Methods covered: get_instance (singleton lifecycle including the null branch); __construct (40+ add_action wirings across admin AJAX endpoints, frontend AJAX endpoints with nopriv counterparts, filter presets admin-only, find-and-replace admin-only, push-row admin-only, and the ob_start output-buffer initialization that prevents stray output from corrupting JSON responses per #113); gt_clean_output_buffers (drain-all-buffers contract verified by stacking three extra buffers on top of the constructors and asserting the drain reaches zero, plus the no-op-when-empty branch); safe_log (silent-when-logger-null guard, level-method dispatch via an anonymous capturing logger, exception swallowing when the logger throws); gt_assert_save_integrity (missing-table_id null short-circuit, table_id-zero null short-circuit, no-existing-row null short-circuit, clean-save passes, empty-title-overwriting-populated returns the integrity_empty_title_overwrites_populated envelope, empty-columns-overwriting-populated returns the integrity_empty_columns_overwrites_populated envelope, non-string-settings json_decode skip); invalidate_table_caches (three group flushes in order gravity_tables / gravity_forms / gf_entries, GFCache flush invocation when the class exists, gf_clear_entry_cache only when an entry_id is supplied &mdash; not on null/omitted); count_wp_placeholders (counts %d, %s, %f, %i; returns 0 for unparameterized SQL; ignores quoted MySQL date format strings so the placeholder count for wpdb prepare matches WordPress own regex; handles empty strings; handles repeated placeholders); format_user_display (Guest fallback for empty/zero/string-zero, User #N marker when user not found, display_name happy path, user_login fallback when display_name is empty); format_date_display (empty-input returns empty, midnight uses date-only output, non-midnight appends time, configured date_format / time_format from gt_settings overrides defaults, unparseable input returns the raw value); convert_gf_date_format_to_mysql (all 7 documented GF format codes round-trip: mdy, dmy, dmy_dash, dmy_dot, ymd_slash, ymd_dash, ymd_dot, plus the unknown-format US fallback); normalize_date_format (zero-pads single-digit month, zero-pads single-digit day, leaves already-padded input intact, passes through non-three-parts input, returns empty for empty); sanitize_field_value (empty-string and null short-circuit; array routes through TC_Inline_Edit_Sanitizer prepare_for_storage; boolean-like yes/no routes to sanitize_text_field; email routes to sanitize_email; URL routes to esc_url_raw; phone routes to sanitize_text_field; ISO date / US date / dash date all pass through sanitize_text_field; integer and decimal numbers pass through; HTML routes to TC_Sanitization_Service sanitize_cell_html with the disallowed script tag stripped; multiline text routes to sanitize_textarea_field; plain default text routes to sanitize_text_field); format_gravity_forms_date and get_gravity_form_date_format (empty-input return, unparseable-returns-raw, US-default-parse when GFAPI is missing, ISO-fallback when the primary format fails &mdash; the GFAPI-present branches ship in sub-slice F where the AJAX entry handlers already alias GFAPI). Strategy notes for the suite-wide playbook: WP_Mock userFunction for get_option (gt_settings reads), get_user_by (format_user_display), wp_cache_flush_group and gf_clear_entry_cache (invalidate_table_caches observation seams) so the shims stay method-scoped and do not bleed across files via Patchwork. In-file function_exists-guarded shims for sanitize_text_field / sanitize_textarea_field / sanitize_email / esc_url_raw / wp_kses because bootstrap does not pre-declare them (tests/helpers/stubs.php declares them but is only loaded by the legacy custom runner). GFCache stub installed at file top with a class_exists guard and a flush_count static counter for observation. gt_assert_save_integrity exercised through the slice 21 SQL-pattern fixture seam (GLOBALS gt_test_wpdb_fixtures get_row) so the SELECT title, settings FROM wpdb prepare round-trip can be staged per-test without a full Mockery wpdb instance. TC_Inline_Edit_Sanitizer and TC_Sanitization_Service are the real production classes (autoloaded by tablecrafter.php before class-tc-ajax.php), so the class_exists branches in sanitize_field_value route through real production logic and the assertions pin the contract end-to-end. PHPUnit risky-test-did-not-close-its-own-output-buffers guard is sidestepped by recording the baseline ob_get_level in setUp and draining back to baseline in tearDown &mdash; the constructor opens one buffer (#113) so every test that touches the singleton would otherwise leak. The two tests that explicitly drain buffers re-open buffers up to baseline before returning so PHPUnit own output capture continues to work for sibling tests in the same process. Patterns reused: slice 56 (GTShortcodeRegisterScriptsTest &mdash; sub-slice A of the TC_Shortcode decomp, the same registration + helpers template); slice 93 (GTLookupTest &mdash; WP_Mock userFunction discipline + private method access via ReflectionMethod invokeArgs); slice 21 (wpdb SQL-pattern fixture seam); slice 28 (GTAdminLoggerSeamTest &mdash; GFCache stub + singleton override seam contract). Shim suite: 2695 tests / 5720 assertions / 0E / 0F / 13 skipped / 1 risky -&gt; 2779 tests / 5829 assertions / 0E / 0F / 13 skipped / 1 risky (+84 / +109). Legacy tools/test-all.sh: EXIT=0 (523 files green, 64 vitest files / 957 vitest tests green). Coverage delta: ~120 statements of includes/class-tc-ajax.php (registration wiring + the listed small helpers) pinned at ~100%. The remaining ~3490 statements ship across sub-slices B through L (slices 95-105, v5.9.0 -&gt; v6.0.0 under the bump-at-9 rule). This slice OPENS the TC_Ajax decomposition &mdash; the biggest mega-file in the codebase, the second-to-last decomp target before TC_Admin closes out the 100% campaign. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.8.8</strong> &mdash; <?php _e('Test coverage (slice 93 &mdash; pin TC_Lookup to ~100%; CLOSES the partial-fill campaign). Last partial-fill slice. Targets includes/class-tc-lookup.php (294 statements) from a 1.36% baseline to ~100% lines / ~100% methods. Every non-mega-file production class is now pinned &mdash; only the TC_Ajax (3611 statements at 14.71%) and TC_Admin (1061 statements at 6.79%) mega-file decompositions remain. New tests/GTLookupTest.php (76 tests / 113 assertions). TC_Lookup is the singleton value-transformation layer used by TC_Entry_Repository (slice 92) and the shortcode display path. It resolves three lookup types: user (returns one of six profile fields display_name, user_login, user_email, first_name, last_name, user_nicename); post (returns one of four post fields post_title, post_excerpt, post_status, post_type); custom-table (looks up a row in any allowlisted custom table via TC_SQL_Guard is_safe_identifier). Provides single-value (process_lookup_value) and batched (process_lookup_values_batch) transforms plus dropdown-source generation (get_lookup_options) that intersects the configured source with the actual values referenced in a given Gravity Forms form via the wp_gf_entry / wp_gf_entry_meta join. Coverage targets every branch: singleton lifecycle (get_instance null-branch and identity, class-shape contract); process_lookup_value guard branches (empty-value, empty-config, missing-type, unknown-type) and dispatch to lookup_user, lookup_post, lookup_custom; lookup_user (falsy-id early return, user-not-found, all six user_field branches including first_name and last_name via get_user_meta, unknown-field fallback to display_name); lookup_post (falsy-id early return, post-not-found, all four post_field branches, unknown-field fallback to post_title); lookup_custom (table / id_column / display_column empty branches; inline preg_match reject paths for unsafe identifiers; wpdb get_var hit returns the display value, miss returns the original id); process_lookup_values_batch (same guard branches plus type dispatch); lookup_users_batch (intval-filter early-return when nothing survives, all six user-field branches plus unknown-field fallback); lookup_posts_batch (intval-filter early-return, all four post-field branches plus unknown-field fallback); lookup_custom_batch (column-empty guards, three TC_SQL_Guard reject paths for unsafe identifiers, happy-path result mapping keyed by id); get_lookup_options (type guards plus dispatch); get_user_options (no-form-id fallback with number 100 cap, form-id used-ids intersect via gf_entry / gf_entry_meta join, user_roles and lookup_user_roles config aliases both fire role__in, all six label-field branches plus unknown-field fallback, empty-label skip via the not-empty label guard); get_post_options (no-form-id fallback, form-id used-ids intersect, all four label-field branches plus unknown-field fallback, empty-label skip, custom post_type config); get_custom_options (column-empty guards, three TC_SQL_Guard reject paths, no-form-id raw SELECT LIMIT 100 fallback, form-id used-values intersect via wp_gf_entry_meta, empty-used-values short-circuit that skips the custom-table SELECT entirely). Private methods reached via Reflection. Strategy: real TC_Lookup singleton (reset between tests via reflection &mdash; PHP 8.5 setAccessible(true) deprecation avoided per suite lesson 4; no-op since PHP 8.1) under a Mockery-mocked wpdb (slice 92 pattern) for the get_options paths that hit the gf_entry / gf_entry_meta joins and the custom-table SELECT. WP_Mock userFunction for every WP surface (get_user_by, get_users, get_user_meta, get_post, get_posts, sanitize_text_field) &mdash; bootstrap does not declare them so WP_Mock owns them cleanly without parse-time races. Real TC_SQL_Guard is_safe_identifier for the centralised allowlist branches. Test fixture stores keyed by id so per-test setup is one-line. Patterns reused: slice 92 (GTEntryRepositoryTest) Mockery-mocked wpdb typehint placeholder plus singleton-reset via reflection; slice 91 (GTDebugTest) WP_Mock userFunction discipline; slice 90 (GTConfigurationServiceTest) get_option / update_option parse-time race avoidance; slice 76 (GTSqlGuardTest) real TC_SQL_Guard is_safe_identifier. Shim suite: 2619 tests / 5607 assertions / 0E / 0F / 13 skipped / 1 risky -&gt; 2695 tests / 5720 assertions / 0E / 0F / 13 skipped / 1 risky (+76 / +113). Legacy tools/test-all.sh: EXIT=0 (523 files green, 64 vitest files / 957 vitest tests green). Coverage delta: includes/class-tc-lookup.php from 1.36% -&gt; ~100% lines / ~100% methods. This slice closes the partial-fill campaign &mdash; only the TC_Ajax and TC_Admin mega-file decompositions remain. The per-file fill template has now been applied across the entire non-mega-file production surface. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.8.7</strong> &mdash; <?php _e('Test coverage (slice 92 &mdash; pin TC_Entry_Repository to 100%; FIFTH and FINAL slice of batch 12, partial-coverage catch-up). Replaces the stub tests/GTEntryRepositoryTest.php (1 test / 1 assertion) with a comprehensive 74-test / 113-assertion file covering includes/repositories/class-tc-entry-repository.php (283 statements) from a 2.83% baseline to 100% lines / 100% methods. TC_Entry_Repository is the data-access layer behind TC_Entry_Service (slice 63); it wraps three collaborators (TC_Query_Builder, TC_Lookup singleton, TC_Configuration_Service) and encapsulates every $wpdb query against wp_gf_entry / wp_gf_entry_meta that the entry-display layer needs. Coverage targets every branch: findById hit and miss; findByIds empty-input early-return, intval coercion, null-result fallback; getEntryMeta keyed-map happy path with field_ids filter; updateEntryMeta success, multi-field, returns-false-no-error, throws TC_Database_Exception when wpdb-last_error is set; bulkUpdate counts payload with empty entry_ids and partial-failures; bulkDelete success counts with intval coercion and exception when last_error is set; canUserEditEntry admin short-circuit via user_can edit_posts, meta-exists path, no-meta path; getForExport GFAPI-missing class_exists branch (process-isolated), form-missing early-return, happy-path with form and entries payload (all three branches under process isolation); getUniqueValues DISTINCT query against gf_entry_meta; getValueCountsAndFirstSeen typed-payload mapping (value, count, first_seen) and non-array null fallback; getUniqueValuesMatching no-search, with-search LIKE clause, limit clamp low to 1, limit clamp high to 200, non-array null fallback; countByFormId defaults and every filter shape; findByFormId default payload, processes rows and computes total_pages, applies pagination offset, all-filter combinations, custom-filter switch arms for text, dropdown, lookup, checkboxes, date_range, number_range plus default-arm and skip arms for non-array filters and missing-type guards. Private helpers reached via Reflection: applyCustomFilters every branch including all skip guards; applyDateRangeFilter from-only, to-only, both; applyNumberRangeFilter min-only, max-only, both, non-numeric skip; applySorting entry_id branch, date_created branch, numeric-type CAST branch, text-type plain orderBy branch, lookup routing branch, fallback-to-date_created arm; applyLookupSorting orderBy fallback; processEntryResults empty-rows, dotted field id alias path (4.1 maps to field_4_1), missing-field falls back to empty string, lookup-processor invocation arm; formatDateDisplay empty-input, midnight-uses-date-format, time-set-uses-datetime-format, unparseable-returns-raw; normalizeDateFormat empty-input, zero-pads single-digit MM/DD, passes-through non-three-parts, already-padded preserved. Strategy: real TC_Query_Builder + TC_Configuration_Service + TC_Lookup under a Mockery-mocked $wpdb so the SQL paths execute end-to-end and only the final wpdb call returns fixture data. TC_Database_Exception::fromWpdb has a wpdb typehint &mdash; we declare a structural placeholder class wpdb at file top (mirrors GTExceptionsTest slice 27) and build mocks via Mockery::mock(wpdb::class) so they satisfy instanceof wpdb. WP_Mock::userFunction for every WP function (current_time, user_can, get_option, update_option) &mdash; post-slice-91 lesson: parse-time function_exists shims bleed across files; Patchwork-rewritten WP_Mock interceptions stay method-scoped. getForExport branches exercised under PHPUnit process isolation with Mockery::mock(alias:GFAPI) (slice 40 GTMergedTableTest pattern); the GFAPI-missing branch requires process isolation because sibling tests alias declarations leak. TC_Lookup singleton reset between tests via reflection; PHP 8.5 setAccessible(true) deprecation avoided per suite lesson 4 &mdash; the call is a no-op since PHP 8.1. Patterns reused: slice 63 (GTEntryServiceTest) Mockery-wpdb and TC_Lookup-singleton-reset template; slice 27 (GTExceptionsTest) structural wpdb stub; slice 40 (GTMergedTableTest) GFAPI alias mocking; slice 90 (GTConfigurationServiceTest) WP_Mock get_option / update_option; slice 91 (GTDebugTest) WP_Mock for ALL WP surface to keep shims method-scoped. Shim suite: 2546 tests / 5495 assertions / 0E / 0F / 13 skipped / 1 risky -&gt; 2619 tests / 5607 assertions / 0E / 0F / 13 skipped / 1 risky (+73 / +112). Legacy tools/test-all.sh: EXIT=0 (523 files green, 64 vitest files / 957 vitest tests green). Coverage delta: includes/repositories/class-tc-entry-repository.php from 2.83% -&gt; 100% lines / 100% methods. This slice CLOSES batch 12 (slices 88-92, v5.8.3 -&gt; v5.8.7) &mdash; the partial-coverage catch-up batch landed in five sequential releases: TC_AI_Ajax (~100% via slice 88), TC_Clipboard_Paste_Handler (97.73% lines via slice 89), TC_Configuration_Service (~100% via slice 90), TC_Debug (~100% via slice 91), TC_Entry_Repository (100% via slice 92). Total batch delta: +253 tests / +542 assertions across 5 production files moved from a weighted-average ~14% baseline to ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.8.6</strong> &mdash; <?php _e('Test coverage (slice 91 &mdash; pin TC_Debug to ~100%; FOURTH slice of batch 12, partial-coverage catch-up). New tests/GTDebugTest.php (60 tests / 127 assertions) pins includes/class-tc-debug.php (245 statements) from 7.76% to ~100%. Strategy: WP_Mock::userFunction for ALL WP surface (post-slice-90 lesson &mdash; Patchwork wins over parse-time function_exists shims and keeps interception method-scoped). The two parse-time shims that remain (current_user_can / get_current_user_id) read from the post-#1206 globals (_gt_test_user_caps / _gt_test_current_user_id) so they do not shadow alphabetically-later test files. Shim suite: 2486 tests / 5368 assertions / 0E / 0F -&gt; 2546 tests / 5495 assertions / 0E / 0F (+60 / +127). Legacy 523/523 + 2 allowed-fail. Vitest 957/957. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.8.5</strong> &mdash; <?php _e('Test coverage (slice 90 &mdash; pin TC_Configuration_Service to ~100%; THIRD slice of batch 12, partial-coverage catch-up). Third slice of batch 12 &mdash; the partial-coverage catch-up batch that picks up files left between 0% and 100% by the prior 0%-file campaign. Targets includes/services/class-tc-configuration-service.php (122 statements) from a 19.67% baseline to ~100%. New tests/GTConfigurationServiceTest.php (66 tests / 112 assertions). TC_Configuration_Service is the centralised plugin-settings container &mdash; a pure-PHP class with a handful of WordPress integration points (get_option / update_option for persistence, __() for the one i18n error string in validateConfiguration). It exposes static constants for default per-page (25), min and max per-page (1 and 100), default date and datetime formats, and three field-type allowlists (non-editable IDs like id / date_created / date_updated; filterable types; sortable types). The constructor merges a caller-supplied overrides array over getDefaultSettings so arbitrary extra keys flow through unchanged. get is a null-coalesce read with a caller-supplied default; set is a direct write. getPerPage clamps the merged per_page setting to the MIN_PER_PAGE / MAX_PER_PAGE range after an int cast, so a stored string 17 becomes int 17, a stored 0 floors to 1, and a stored 9999 ceilings to 100. getDateFormat and getDateTimeFormat round-trip with the defaults. isFieldEditable with a non-empty field id short-circuits to false when the field id is in the non-editable list (so a text field with id date_created is still blocked from edit), otherwise reads the field-type list; an empty field id falls through cleanly to the type check. isFieldFilterable and isFieldSortable are pure list lookups &mdash; the test pins their differences (textarea is filterable but NOT sortable; hidden is filterable; signature is neither). getAllowedBulkActions returns the merged setting (defaults to delete and export); getCacheDuration floors negatives to zero after an int cast; getMaxEntriesPerRequest floors to one. isDebugLoggingEnabled returns the explicit opt-in enable_debug_logging OR the WP_DEBUG constant truth &mdash; the explicit-true branch is asserted directly, the WP_DEBUG branch is verified to match defined(WP_DEBUG) AND WP_DEBUG so the test is parse-order-independent. getFieldDefaults walks a switch over field type and returns per-type filter_config shapes: date returns type date, date_range single, show_presets true; number and phone share type range, range_step 1, range_format number; select and radio share type dropdown, multiple false, sort_options alphabetical; multiselect and checkbox share type checkboxes, checkboxes_logic or, show_select_all true; everything else falls through the default arm to type text, case_sensitive false, exact_match false. Each switch case has a dedicated test, plus a non-editable-field case (source_url) that pins all three booleans (editable, filterable, sortable) to false on the same return shape. createTableConfiguration is instance-checked against TC_Table_Configuration and verified to layer overrides over the merged settings (a service-level per_page plus a call-time form_id override both survive into the configuration object). validateConfiguration is the only public method that can throw &mdash; the test exercises every path: per-page in range (50 passes), per-page below MIN_PER_PAGE (0 throws TC_Validation_Exception), per-page above MAX_PER_PAGE (9999 throws), boolean coercion (string true, integer 1, boolean false round-trip through TC_Sanitization_Service sanitizeBoolean correctly), bulk-actions intersection with the delete / export / edit allowlist (hack_action filtered out, the three allowed values preserved), non-array bulk_actions falls through and the default carries through, an unrelated custom_key in the existing settings survives the merge, and an empty config returns the defaults without raising. toArray returns the full settings map (asserted for an overridden key plus two structural-key existence checks). loadFromOptions and saveToOptions are tested in three shapes: (1) loadFromOptions merges a previously-saved array over the defaults; (2) when the stored option is a non-array (e.g. string corrupted), the is_array guard skips the merge and the defaults remain untouched; (3) when no option is stored at all, the call is a no-op. saveToOptions writes and returns true on success; the test also forces update_option to return false and asserts the method propagates that. A round-trip test save-and-reload through a fresh service confirms the option key is gravity_tables_settings end-to-end. Strategy: WP_Mock-backed get_option and update_option shims sidestep the function-declaration race against sibling tests (GTAutoloadManagerTest, GTTemplateServiceTest, GTEntryServiceTest, test-issue-505-api-key-service.php, and others) that each declare their own under function_exists guards &mdash; whichever file loads first under the PHPUnit directory scan wins the global namespace, and Patchwork cannot intercept once a real PHP function exists. WP_Mock Patchwork-backed userFunction declaration always wins regardless of declaration order. __ and sanitize_text_field shimmed at file top under function_exists guards (slice-41 pattern) so the dependency chain (TC_Validation_Service, TC_Sanitization_Service, TC_Validation_Exception, TC_Table_Configuration) parses cleanly. No wpdb, no GFAPI, no process isolation needed. Patterns reused: slice 41 GTConfigPortServiceTest (sibling service-class layout + function_exists-guarded shims at file top); slice 63 GTEntryServiceTest (comprehensive service-class template); slice 89 GTClipboardPasteHandlerTest (WP_Mock userFunction for option-store overrides). Shim suite: 2420 tests / 5256 assertions / 0E / 0F / 13 skipped / 1 risky -&gt; 2486 tests / 5368 assertions / 0E / 0F / 13 skipped / 1 risky (+66 / +112). Legacy tools/test-all.sh: EXIT=0 (523 files green, 64 vitest files / 957 vitest tests green). Coverage delta: includes/services/class-tc-configuration-service.php from 19.67% -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.8.4</strong> &mdash; <?php _e('Test coverage (slice 89 &mdash; pin TC_Clipboard_Paste_Handler to ~100%; SECOND slice of batch 12, partial-coverage catch-up). Second slice of batch 12 &mdash; the partial-coverage catch-up batch that picks up files left between 0% and 100% by the prior 0%-file campaign. Targets includes/services/class-tc-clipboard-paste-handler.php (89 statements) from a 14.61% baseline to ~100% line coverage (97.73% &mdash; 86/88 lines; the two not-covered lines are defensive 500 guards on class_exists(TC_TSV_Parser_Service) and class_exists(TC_Admin) and class_exists(GFAPI) whose false arm cannot fire under the shim because tests/bootstrap.php pre-loads all three classes &mdash; pinned via source-shape guards instead). New tests/GTClipboardPasteHandlerTest.php (39 tests / 146 assertions). TC_Clipboard_Paste_Handler is the admin-side AJAX surface behind the issue #516 paste-from-Excel-Sheets-Numbers affordance. It registers two wp_ajax_* hooks: wp_ajax_gt_clipboard_paste_preview (parse a clipboard payload via TC_TSV_Parser_Service, return dialect + row count + a 5-row preview &mdash; no DB writes) and wp_ajax_gt_clipboard_paste_apply (parse the payload, optionally trash existing entries when mode equals replace, then create one Gravity Forms entry per parsed row via GFAPI add_entry). It also exposes the pure helper rewire_columns_by_header which case-insensitively maps a parsed header row to the table saved column ids, falling back to null on partial / empty / no-match so the apply path can safely revert to positional mapping. Coverage targets every branch: boot() registers both AJAX actions and pairs them with ajax_preview / ajax_apply exactly (class-shape Reflection contract pinned for all three public static methods as : void with zero parameters); ajax_preview 403 when manage_options denied; ajax_preview 400 when POST tsv is empty AND when the tsv key is absent (covers the isset ternary false arm); ajax_preview 200 happy path with dialect detection (tsv when tabs present, csv when only commas), rows and cols count round-trip, and the 5-row preview cap honoured even when the input is 10+ rows; wp_unslash flow verified by feeding magic-quotes-style backslash-escaped quotes and asserting the unescaped string lands in the preview. ajax_apply 403 when manage_options denied; 400 when table_id is missing, 0, or tsv is empty; 404 when the TC_Admin override returns null for the requested table_id; 400 when the row exists but form_id is 0 (table not bound to a GF); invalid mode values coerced to append; missing mode defaults to append. Append happy path asserts GFAPI add_entry is called once per parsed row with the GF entry payload shape (form_id + column-id-keyed cell values). Replace happy path asserts GFAPI get_entries is called with the canonical status active filter + offset 0 page_size 5000 paging, then GFAPI update_entry status trash plus id once per existing entry, then add_entry once per new row &mdash; with the is_array existing guard exercised separately by returning a WP_Error from get_entries (deleted stays at 0, added still proceeds), and the not-empty entry id skip exercised by feeding rows of id 0, no id, and id 99 then asserting only entry 99 is trashed. Header rewire happy path: has_headers 1 with column_labels 3 Driver 5 Status and headers Status Driver reverses the column ids to 5 3 so the captured add_entry payload routes complete to field 5, Bob to field 3 rather than the positional complete to field 3, Bob to field 5 mis-mapping. Partial match falls back to positional (Driver Unknown &mdash; rewire returns null so effective_columns stays at the saved positional order). Missing column_labels config falls through the same null-rewire path. add_entry WP_Error return: counted into the errors total without breaking the loop (asserts added 1 errors 1 for a 2-row paste where the first add_entry returns WP_Error). No-saved-columns config: effective_columns or null flips to null, to_rows uses the first parsed row as header keys, the GF entry payload is keyed by the parsed header strings rather than numeric column ids. rewire_columns_by_header contract pinned (6 tests): null on empty headers; full match reorders; case-insensitivity + whitespace trim; partial match returns null; empty-trimmed header cell aborts; column without a saved label is excluded from the index. Source-shape guards: no wp_remote_*, no curl_exec, no HTTP file_get_contents, the canonical gt_clipboard_paste nonce action literal is present, both AJAX handlers gate on current_user_can manage_options (counted via substr_count so a future drop of either gate flags here), the is_scalar val and string val casts in the row-map to entry loop are present. Strategy: uses the slice-20 wp_send_json_* throw seam (GLOBALS gt_test_send_json_throws equals true) so each handler short-circuits and the test inspects the captured payload from GLOBALS gt_test_response. WP_Mock userFunction overrides for current_user_can / __ / wp_unslash are installed in setUp &mdash; the PHPUnit suite does not load tests/helpers/stubs.php, so without these overrides Patchwork has no real declaration to race against and the production reads see undefined-function fatals. The slice-28 GLOBALS gt_test_admin_override seam plugs in an anonymous-class fake TC_Admin exposing only the two methods the handler reads (get_table id, get_table_config id) so each test can configure the fixture row + config in one line. Mockery::mock alias:GFAPI with per-test shouldReceive expectations covers the add_entry / get_entries / update_entry calls &mdash; same pattern as slice 40 (GTMergedTableTest), slice 48 (GTWooCommerceTest). No wpdb, no process isolation needed since the dependency-not-loaded 500 guards are unreachable under the shim. Patterns reused: legacy tests/test-issue-516-slice2-clipboard-paste.php and tests/test-issue-516-slice3-header-match.php (source-shape + rewire contract); slice 88 GTAiAjaxTest (throw-seam + Reflection contract pinning template); slice 40 GTMergedTableTest (TC_Admin override + GFAPI alias); slice 47 GTExternalDBTest (AJAX-style handler test layout). Shim suite: 2381 tests / 5110 assertions / 0E / 0F / 13 skipped / 1 risky -&gt; 2420 tests / 5256 assertions / 0E / 0F / 13 skipped / 1 risky (+39 / +146). Legacy tools/test-all.sh: EXIT=0 (523 files green, 64 vitest files / 957 vitest tests green). Coverage delta: includes/services/class-tc-clipboard-paste-handler.php from 14.61% -&gt; 97.73% lines. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.8.3</strong> &mdash; <?php _e('Test coverage (slice 88 &mdash; pin TC_AI_Ajax to ~100%; FIRST slice of batch 12, partial-coverage catch-up). First slice of batch 12 &mdash; the partial-coverage catch-up batch that picks up files left between 0% and 100% by the prior 0%-file campaign. Targets includes/services/ai/class-tc-ai-ajax.php (26 statements) from a 19.23% baseline to ~100%. New tests/GTAiAjaxTest.php (15 tests / 44 assertions). TC_AI_Ajax is the AJAX surface for the AI column-type detector &mdash; it exposes a single hook (wp_ajax_gt_ai_detect_column_types) routed at TC_AI_Ajax handle_detect_column_types, which gates on nonce + manage_options capability + non-zero form_id, then delegates to TC_AI_Column_Type_Detector detect and serialises the result via wp_send_json_success or wp_send_json_error. Coverage targets every branch: invalid-nonce 403 path; missing-nonce-request-param 403 path; insufficient-capability 403 path; missing-form_id 400 path; form_id = 0 400 path; detector returning WP_Error 400 path with code + message round-tripped through the response payload; sample_size = abc -&gt; absint -&gt; 0 -&gt; falsy -&gt; fallback to 25 (still hits the no-provider WP_Error arm so the absint coercion is exercised without needing an HTTP stub); explicit sample_size = 50 flowing through unchanged. register() action wiring is covered too &mdash; the test clears the bootstrap add_action capture array, calls register, then asserts the wp_ajax_gt_ai_detect_column_types hook holds the TC_AI_Ajax handle_detect_column_types callable pair exactly. Class-shape guards pin register and handle_detect_column_types as public static : void with zero parameters via Reflection, plus source-level invariants (no wp_remote_*, no curl_exec, no HTTP file_get_contents, references the detector class, uses the canonical nonce action). Uses the slice-20 wp_send_json_* throw seam (GLOBALS gt_test_send_json_throws = true) so the handler short-circuits and the test can inspect the captured payload from GLOBALS gt_test_response. WP_Mock userFunction overrides for get_option / update_option / wp_verify_nonce / current_user_can / __ / absint / gt_is_premium are installed in setUp so the first-shim-wins lesson does not collide with later AI tests in the same PHPUnit process. No wpdb, no GFAPI, no process isolation. Patterns reused: legacy tests/test-issue-496-ai-column-type-detection.php (auth-gate short-circuit assertions via the same throw seam); slice 30 GTAiSettingsEncryptionTest (WP_Mock userFunction for option-store overrides); slice 87 GTAiProviderInterfaceTest (sibling AI interface pinning). Shim suite: 2366 tests / 5066 assertions / 0E / 0F / 13 skipped / 1 risky -&gt; 2381 tests / 5110 assertions / 0E / 0F / 13 skipped / 1 risky (+15 / +44). Legacy tools/test-all.sh: EXIT=0 (523 files green, 64 vitest files / 957 vitest tests green). Coverage delta: includes/services/ai/class-tc-ai-ajax.php from 19.23% -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.8.2</strong> &mdash; <?php _e('Fixed (#1206): repair PHPUnit shim suite &mdash; 14E/5F -&gt; 0E/0F (test-infra only). The PHPUnit shim suite (vendor/bin/phpunit) had 14 errors + 5 failures on main that were invisible to tools/test-all.sh and tools/build-release.sh. All in two files: tests/GTImportTest.php (14E + 4F) and tests/GTExceptionsTest.php (1F). Root cause: two compounding parse-time function-symbol races. (1) tests/GTShortcodeExternalSourceTest.php declares set_transient at file-parse time. PHPUnit directory scan loads every Test.php file before tests run, so its global shim wins the function-symbol race against GTImportTest WP_Mock userFunction closure (Patchwork cannot intercept once a real PHP function exists). The global shim wrote raw values to GLOBALS _gt_test_transients while GTImportTest read from a separate this transients property under a value plus ttl wrapper, so assertions saw an empty array on every transient read. (2) tests/GTErrorHandlerTest.php declares get_current_user_id returning literal 7 at parse time. Same race fires here: under the full suite the production code keyed transients as gt_import_result_7 while GTImportTest assertions looked for gt_import_result_1, AND the GTExceptionsTest permission-exception fallback test assertSame 0 saw 7 instead. Fix shape (smallest viable, test-only): bind this transients by reference to GLOBALS _gt_test_transients and drop the wrapper schema so both shim paths converge on the same backing store; make GTErrorHandlerTest get_current_user_id runtime-overridable via GLOBALS _gt_test_current_user_id with default 7 fallback so GTImportTest setUp value of 1 takes effect without losing the file existing assertSame 7 regression guard; rewrite the GTExceptionsTest fallback test to drive GLOBALS _gt_test_current_user_id to 0 in a try-finally and assert getUserId equals get_current_user_id so the test is order-independent. No production code touched (proven by isolating each failure: the test files passed when run standalone via vendor/bin/phpunit tests/GTImportTest.php, only failing under the full directory scan). Shim suite delta: 2366 tests / 5038 assertions / 14E / 5F / 11 skipped -&gt; 2366 tests / 5066 assertions / 0E / 0F / 13 skipped / 1 risky (the risky is the pre-existing PHPUnitShimTest testIssueFiles no-assertion guard, untouched by this PR). GTErrorHandlerTest unaffected (still 39 tests / 85 assertions). Legacy tools/test-all.sh: EXIT=0 (523 files green, 64 vitest files / 957 vitest tests green). No customer-facing runtime change &mdash; this is test infrastructure repair so future PR regressions in modern test files surface against the release gate.', 'tc-data-tables'); ?></li>
                    <li><strong>5.8.1</strong> &mdash; <?php _e('Fixed (#1197): TC_Table_Builder::validate_config TypeError on every call. The method declared : bool but unconditionally returned the errors array. PHP threw TypeError on every invocation, including the empty-error happy path. Fix: change return type to : array to match existing semantics. Callers check empty(errors) or iterate. Surfaced by slice 65 coverage push; closed here. Three slice-65 tests flipped from expectException(TypeError) to asserting the actual array shape: empty config returns 3 missing-field errors, missing form returns 1 does not exist error, valid config returns empty array. Shim 14E/5F unchanged (#1206 pre-existing). Legacy 523/523 + 2 allowed-fail. Vitest 957/957.', 'tc-data-tables'); ?></li>
                    <li><strong>5.8.0</strong> &mdash; <?php _e('Test coverage (slice 87 &mdash; pin TC_AI_Provider interface at ~100%; CLOSES batch 11 + CLOSES the 0%-file campaign). Fifth and final slice of batch 11 &mdash; and the final slice in the 0%-file campaign across batches 7-11 (slices 63-87, v5.5.6 -&gt; v5.8.0). After this lands, every 0% file in includes/ is pinned at ~100% line coverage. New tests/GTAiProviderInterfaceTest.php (13 tests / 26 assertions) pins includes/services/ai/interface-gt-ai-provider.php (2 statements) from 0% -&gt; ~100%. TC_AI_Provider is the single seam every AI feature talks to &mdash; feature code resolves a provider via TC_AI_Provider_Registry::active() and calls complete() against the interface, never against a vendor SDK directly. The interface declares four methods: is_configured returning bool, provider_id returning string, label returning string, and complete taking a string prompt plus an optional array options parameter and returning either a string or a WP_Error. The interface declaration itself does not contribute executable statements; the only line PHPUnit can attribute coverage to is the if not defined ABSPATH guard. Defining ABSPATH before requiring the file pins it at ~100%. Strategy: Reflection-based contract test pins the four public method names via a sorted-array assert; return types pinned for is_configured returns bool, provider_id returns string, label returns string; complete parameter shape pinned (required string prompt, optional array options with default empty array); complete return type is intentionally undeclared because the union string-or-WP_Error is a PHP-8.0 union type that the supported PHP-7.x install base cannot parse &mdash; pinned via getReturnType equals null so a future PR that adds a stricter return type without coordinating callers surfaces here. Three concrete stub providers (TC_AI_Provider_OpenAI, TC_AI_Provider_Anthropic, TC_AI_Provider_Gemini) are each asserted to implement the interface via class_implements AND instantiate as a valid TC_AI_Provider &mdash; the exact contract TC_AI_Provider_Registry::active relies on at its return point (the registry resolves a class name from a settings map and constructs it, then narrows the return type via instanceof TC_AI_Provider). Any future PR that strips an implements clause from a concrete provider or renames a method on the interface breaks the test before it can break the registry. No wpdb, no GFAPI, no hook registration, no process isolation, no inline-style enqueue. ABSPATH is defined-guarded so a future process-isolated test does not surface an undefined-constant warning when this file is the first one to load. Patterns reused: slice 27 (GTExceptionsTest &mdash; interface / abstract contract pinning via Reflection); slice 86 (GTRtlServiceTest &mdash; concise per-slice service test template). Shim suite: 2353 tests / 5012 assertions -&gt; 2366 tests / 5038 assertions (+13 / +26). Pre-existing 14E/5F count (per #1206) is unchanged. Legacy tools/test-all.sh: EXIT=0. Vitest: 64 files / 957 tests &mdash; unchanged. Coverage delta: includes/services/ai/interface-gt-ai-provider.php from 0% -&gt; ~100%. Batch 11 close-out (slices 83-87, v5.7.6 -&gt; v5.8.0): all five files in this batch (TC_Excel_Float_Service, TC_Accessibility_Service, TC_Fuzzy_Search_Service, TC_RTL_Service, TC_AI_Provider interface) now sit at ~100% line coverage. 0%-file campaign close-out (batches 7-11, slices 63-87, v5.5.6 -&gt; v5.8.0): every originally-0% file in includes/ is now pinned. bump-at-9 leading-digit increment exercised at v5.8.0 (5.7.9 + 1 patch would have been the illegal 5.7.10, so the leading digit ticks). No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.7.9</strong> &mdash; <?php _e('Test coverage (slice 86 &mdash; pin TC_RTL_Service at ~100%). Fourth slice of batch 11 &mdash; the final 0% batch in the 100% coverage push. New tests/GTRtlServiceTest.php (9 tests / 9 assertions / 1 skipped) pins includes/services/class-tc-rtl-service.php (13 statements) from 0% -&gt; ~100% line + method coverage. TC_RTL_Service is the thin right-to-left language presenter that exposes four static helpers consumed by the shortcode renderer and the asset enqueue pipeline. Four public static methods, all exercised. is_rtl() covered &mdash; delegates to the WP global is_rtl() under a function_exists guard so the service stays safe to call before the WP locale stack is fully wired (pinned by flipping a controllable GLOBALS _gt_test_is_rtl flag and asserting both the LTR (false) and RTL (true) return values). get_dir_attr() covered &mdash; pure ternary over is_rtl() emitting either dir=rtl (RTL languages like Arabic, Hebrew, Persian) or dir=ltr (every other locale) for the wrapper div that hosts the table; both arms asserted with literal-string equality so any change to the attribute shape (e.g. dropping quotes, swapping single-quotes) is flagged. get_wrapper_classes() covered &mdash; pure ternary over is_rtl() emitting either gt-rtl or gt-ltr for the wrapper element so theme CSS can target the directional variant without re-querying body[dir] (both arms asserted). enqueue_rtl_styles() covered &mdash; short-circuit branch verified when is_rtl() returns false (no wp_enqueue_style call recorded against our handle, and the structured-capture key stays empty); passthrough branch verified when is_rtl() returns true (the gt-frontend-rtl handle is registered, the dependency is gravity-tables-frontend &mdash; matching the post-v4.8.14 handle from class-tc-shortcode.php register_assets so the legacy gt-frontend mismatch noted in the inline comment can never regress, the cache buster is TC_VERSION, and the src path contains assets/css/frontend-rtl.css). Strategy notes: is_rtl() is not declared by tests/bootstrap.php, so an in-file shim under a function_exists guard wins the first-load race for the entire PHPUnit process; the shim reads GLOBALS _gt_test_is_rtl so setUp can default to LTR and each per-test assignment flips the branch deterministically. wp_enqueue_style uses the dual-key shim pattern documented in slice 84 (GTAccessibilityServiceTest) and slice 82 (GTScrollButtonsServiceTest) &mdash; each call pushes to both the sibling 1-arg capture key (_gt_test_enqueued_styles &mdash; used by GTFormRendererTest, GTMapTest, GTScrollButtonsServiceTest, GTAccessibilityServiceTest) AND our own structured key (_gt_test_rtl_enqueued_styles) so whichever declaration wins the function_exists first-load race, at least one capture key is observable; the structured-shape assertion (handle / deps / src / ver round-trip) degrades gracefully via markTestSkipped when a no-op variadic sibling shim wins instead. TC_PLUGIN_URL and TC_VERSION are re-defined under defined() guards so a future process-isolated test does not surface an undefined-constant warning when this file is the first one to load. No wpdb, no GFAPI, no hook registration, no process isolation needed since there are no class_exists branches or alias declarations. Patterns reused: slice 84 (GTAccessibilityServiceTest &mdash; dual-key wp_enqueue_style capture + skip-when-shim-lost pattern), slice 82 (GTScrollButtonsServiceTest &mdash; sibling enqueue shim layout), slice 85 (GTFuzzySearchServiceTest &mdash; concise per-slice service test template). Shim suite: 2344 tests / 5003 assertions -&gt; 2353 tests / 5012 assertions (+9 / +9). Pre-existing 14E/5F count (per #1206) is unchanged. Legacy tools/test-all.sh: EXIT=0. Vitest: 64 files / 957 tests &mdash; unchanged. Coverage delta: includes/services/class-tc-rtl-service.php from 0% -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.7.8</strong> &mdash; <?php _e('Test coverage (slice 85 &mdash; pin TC_Fuzzy_Search_Service at ~100%). Third slice of batch 11 &mdash; the final 0% batch in the 100% coverage push. New tests/GTFuzzySearchServiceTest.php (15 tests / 43 assertions) pins includes/services/class-tc-fuzzy-search-service.php (17 statements) from 0% -&gt; ~100% line + method coverage. TC_Fuzzy_Search_Service is the stateless static-method helper that powers the per-table enable_fuzzy_search setting (#192). When the setting is on, the AJAX entry query expands each whitespace-separated word in the search term to also match via MySQL SOUNDEX so common typos and phonetically-similar strings surface results (Smth matches Smith, colour matches color, Jon matches John or Joan). The LIKE substring clause is always present so exact matches stay fast and correct; SOUNDEX is appended as an OR so it only widens the result set, never narrows it. Two public static methods, both exercised. is_enabled() covered &mdash; absent enable_fuzzy_search key returns false (existing tables keep exact-match behaviour); falsy values (false, 0, empty string, the literal zero string, null) return false via the not-empty check; truthy values (true, 1, the literal one string, yes string) return true. build_meta_where(wpdb, search_term, meta_alias=em_search) covered &mdash; return-shape contract (array with sql string key plus params list key); LIKE branch always emitted with the supplied meta-table alias as a meta_value LIKE param; esc_like wildcard escaping verified by feeding a payload containing both percent and underscore metacharacters and asserting the produced LIKE wildcard escapes them inside the wrapping percent-percent frame so a user-supplied percent cannot break out; custom meta_alias propagates into both the LIKE selector and every SOUNDEX selector, with the default em_search literal absent from the output. SOUNDEX OR-branch covered &mdash; single-word term emits exactly one SOUNDEX(meta_value) = SOUNDEX(param) clause with the bound parameter matching the input word; three-word phrase (John Paul Jones) emits three SOUNDEX clauses in declaration order with the LIKE parameter first followed by John, Paul, Jones (4 params total); the mb_strlen less-than-2 guard skips single-character ASCII tokens (a Smith b yields only Smith); empty search term produces only the LIKE clause (params is one entry of two percent signs, no SOUNDEX, no OR separator) because preg_split plus array_filter strip the lone empty token; whitespace-only term likewise emits no SOUNDEX. Trim semantics on tokens pinned &mdash; array_map trim strips leading/trailing whitespace from each split fragment before the length check so a term with embedded tabs and surrounding spaces yields the trimmed tokens as bound SOUNDEX params while the LIKE param preserves the original untrimmed term. Unicode whitespace handling pinned &mdash; preg_split with the unicode flag treats U+00A0 NO-BREAK SPACE as whitespace so a term joined by NBSP splits into two SOUNDEX clauses rather than being treated as one long word. Multibyte glyph guard pinned &mdash; a single CJK character measures as length 1 under mb_strlen and is skipped from the SOUNDEX expansion so the LIKE-only fast path holds for short unicode queries. Strategy notes: production type-hints wpdb, so the same in-file guarded placeholder class pattern from GTWritebackServiceTest (slice 76) and GTExceptionsTest (slice 27) is reused &mdash; class wpdb declared once under a class_exists wpdb false-guard, then a local TC_Test_Fuzzy_WPDB subclass provides the only dependency the production code touches (esc_like) and records every call so the bound LIKE parameter can be asserted. No wpdb mock collision with sibling tests because each test instantiates a fresh stub in setUp. No WP_Mock userFunction, no process isolation, no hook registration, no globals beyond the in-file stub. Patterns reused: slice 76 (GTWritebackServiceTest &mdash; wpdb placeholder guard plus local stub subclass for esc_like recording), slice 72 (GTDateServiceTest &mdash; pure-input service style), slice 73 (GTGlobalSearchServiceTest &mdash; sibling search-service layout). Shim suite: 2329 tests / 4960 assertions -&gt; 2344 tests / 5003 assertions (+15 / +43). Pre-existing 14E/5F count (per #1206) is unchanged. Legacy tools/test-all.sh: EXIT=0. Vitest: 64 files / 957 tests &mdash; unchanged. Coverage delta: includes/services/class-tc-fuzzy-search-service.php from 0% -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.7.7</strong> &mdash; <?php _e('Test coverage (slice 84 &mdash; pin TC_Accessibility_Service at ~100%). Second slice of batch 11 &mdash; the final 0% batch in the 100% coverage push. New tests/GTAccessibilityServiceTest.php (17 tests / 32 assertions) pins includes/services/class-tc-accessibility-service.php (25 statements) from 0% -&gt; ~100% line + method coverage. TC_Accessibility_Service is the WCAG 2.1 AA presenter + enqueue helper that injects ARIA attributes, visually-hidden screen-reader spans, and the gt-accessibility frontend asset bundle. Five public static methods, all exercised. get_aria_sort_attr() covered &mdash; three-branch ternary: column not currently sorted returns aria-sort none (announces the header as sortable but inactive), currently sorted with asc returns aria-sort ascending, currently sorted with desc returns aria-sort descending. Default third-arg branch covered (omitting the dir parameter still routes to ascending via the parameter default). The strict identity check is documented &mdash; uppercase DESC and empty string both fall through to ascending so call sites that pass legacy upper-case or null direction values stay safe. get_visually_hidden_span() covered &mdash; plain text wraps into span class gt-sr-only for the CSS clip-to-1px-box pattern; HTML escaping verified by feeding an injection payload and asserting the raw tags are absent and the entity-encoded form is present (pins the contract against an injection regression); empty string round-trips to an empty span class gt-sr-only. get_aria_label() covered &mdash; plain label emits aria-label Export to CSV; adversarial label with embedded quotes entity-encodes the inner quotes via esc_attr so the attribute boundary cannot be escaped (asserts the inner &amp;quot; substring plus the start/end of the outer quoted attribute). get_aria_describedby() covered &mdash; plain region id emits aria-describedby gt-live-region; adversarial id with attribute-injection payload entity-encodes both inner quotes (the literal double-quote count in the output is exactly 2, the wrapping pair only). enqueue_a11y_assets() covered &mdash; gated by TC_Asset_Enqueue_Gate::page_has_table (issue #546 PageSpeed regression fix: a11y CSS+JS only ship on pages that actually render a table). Gate-false branch: no gt_always_enqueue_assets filter override and no post seeded -&gt; early return; structured-capture key remains empty, neither wp_enqueue_style nor wp_enqueue_script is invoked for the gt-accessibility handle. Gate-true branch: the gt_always_enqueue_assets filter is forced true via the bootstrap-level _gt_test_filters override that tests/bootstrap.php apply_filters stub honours, so the gate short-circuits to true without needing to shim get_post / has_shortcode / has_block. Both enqueues are then observed &mdash; the style handle gt-accessibility is registered with gt-frontend dependency, TC_VERSION cache buster, and the assets/css/accessibility.css src path; the script handle gt-accessibility is registered with jquery dependency, TC_VERSION cache buster, in_footer = true, and the assets/js/gt-accessibility.js src path. Strategy notes: presenter methods exercise the bootstrap esc_html / esc_attr shims (htmlspecialchars-backed with ENT_QUOTES) directly, so escaping is observable. The enqueue gate is driven through the bootstrap apply_filters global-filter override (not through WP_Mock::userFunction) because the bootstrap-level stub respects _gt_test_filters as a per-test return-value seam &mdash; setUp clears any prior override, tests opt in by setting the key, tearDown unsets it. wp_enqueue_style and wp_enqueue_script use the dual-key shim pattern (mirrors slice 80 wp_add_inline_style and slice 82 wp_enqueue_script): each call pushes to both the sibling 1-arg capture key (_gt_test_enqueued_styles / _gt_test_enqueued_scripts &mdash; used by GTFormRendererTest, GTMapTest, GTScrollButtonsServiceTest) AND our own structured key (_gt_test_a11y_enqueued_styles / _gt_test_a11y_enqueued_scripts) so whichever declaration wins the function_exists first-load race, at least one capture key is observable; assertions degrade gracefully via markTestSkipped when a no-op variadic shim wins instead. No process isolation needed since there are no class_exists branches or alias declarations. No wpdb, no GFAPI, no hook registration. Patterns reused: slice 82 (GTScrollButtonsServiceTest &mdash; sibling presenter + enqueue service template, dual-key shim pattern documentation) and slice 71 (GTSkeletonServiceTest &mdash; visual presenter shim contract). Shim suite: 2312 tests / 4928 assertions -&gt; 2329 tests / 4960 assertions (+17 / +32). Pre-existing 14E/5F count (per #1206) is unchanged. Legacy tools/test-all.sh: EXIT=0. Vitest: 64 files / 957 tests &mdash; unchanged. Coverage delta: includes/services/class-tc-accessibility-service.php from 0% -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.7.6</strong> &mdash; <?php _e('Test coverage (slice 83 &mdash; pin TC_Excel_Float_Service at ~100%; OPENS batch 11). First slice of batch 11 &mdash; the final 0% batch in the 100% coverage push. New tests/GTExcelFloatServiceTest.php (24 tests / 70 assertions) pins includes/services/class-tc-excel-float-service.php (26 statements) from 0% -&gt; ~100% line + method coverage. TC_Excel_Float_Service is the static-only presenter over PhpSpreadsheet that fixes #169 &mdash; float and decimal cell values were being routed through Sheet::fromArray(), which casts every value to a PHP string and causes two downstream bugs in Excel: numeric strings like 3.14 are stored with cell type String instead of Numeric so referencing formulas error, and ambiguous strings like 1.5 or 12.30 are mis-identified as dates and re-rendered as 1-May / Dec-30. The service interposes an is_numeric_value() decimal-regex gate between the export pipeline and the cell writer so numeric values are written with explicit DataType::TYPE_NUMERIC and a FORMAT_NUMBER_COMMA_SEPARATED1 number format that pins decimal precision and prevents Excel date auto-formatting. Three public static methods, all exercised against a real PhpOffice PhpSpreadsheet Spreadsheet instance (autoloaded via composer rather than mocked, so DataType + NumberFormat side effects are observable through the actual object graph &mdash; the suite breaks if the PhpSpreadsheet API drifts). is_numeric_value() covered &mdash; int / float fast-path returns true for positive, zero, and negative scalars; non-string / non-int / non-float fallthrough returns false for null, true, false, empty array, array containing a numeric string, stdClass instance; empty-string guard returns false (Excel renders blank cells, not zeros); decimal-regex accepts unsigned (3.14, 1000.00, 0.5), signed (-0.75, +12.30, -1000.00), integer-looking strings (42, -17, +1 &mdash; the optional decimal group makes these legal), whitespace-trimmed inputs (3.14 with surrounding spaces, tab and newline around -5); decimal-regex rejects alphabetic (abc), currency ($3.14), percent (3.14%), comma-separator (3,14), scientific notation (1e5, 2.5E-3), date-like (2025-09-24, 1.2.3, 12/30), bare decimal-point edge cases (single dot, .5 with leading dot, 5. with trailing dot &mdash; the regex requires digits on both sides when a decimal is present). set_typed_value(Worksheet, coord, value) covered &mdash; numeric branch writes TYPE_NUMERIC for 3.14 float, 42 integer, and the 1.5 decimal-string with the FORMAT_NUMBER_COMMA_SEPARATED1 format code applied to getStyle(coord) getNumberFormat() (verified by reading back getFormatCode() and asserting equality with the constant); string branch writes TYPE_STRING for the Hello World literal and leaves the format code at the default FORMAT_GENERAL (the else branch must not touch number format); empty-string writes through TYPE_STRING with value empty; null, true, false flow through string-cast in the else branch without fatal (null -&gt; empty, true -&gt; 1, false -&gt; empty). write_data_row(Worksheet, values, row) covered &mdash; empty array (foreach skips, no fatal, row untouched); single value at row 1 lands at A1 with the numeric type round-tripped; mixed row [1.5, text, 7] progresses across A1, B1, C1 with each cell receiving the correct typed write; row anchor honoured (passing row 5 writes to A5, B5 and leaves A1..B4 null); wide row past column Z (27 sequential values land at A2, ..., Z2, AA2 &mdash; confirms Coordinate::stringFromColumnIndex is being called with a 1-based incrementing counter, not the raw foreach key); associative-key input (name=&gt;Alice, score=&gt;95.5) writes to A3 and B3 in declaration order, confirming the internal col counter is used rather than the array keys. Strategy notes: PhpSpreadsheet is autoloaded via vendor/phpoffice/phpspreadsheet, so the test instantiates a real Spreadsheet + active sheet rather than mocking the Worksheet class. This avoids the Mockery overload-class collision risk with sibling tests that import the same namespace, and it ensures the assertions actually pin the PhpSpreadsheet API contract &mdash; if PhpSpreadsheet renames setCellValueExplicit, drops FORMAT_NUMBER_COMMA_SEPARATED1, or changes the DataType::TYPE_ constants in a future major bump, our suite catches it immediately rather than silently passing on stale mocks. No wpdb, no GFAPI, no globals, no hook registration. No process isolation needed since there are no class_exists(GFAPI) branches or alias declarations. Patterns reused: slice 72 (GTDateServiceTest &mdash; sibling pure-logic format-translation service) and slice 80 (GTBorderServiceTest &mdash; sibling pure-logic service test template). Shim suite: 2288 tests / 4858 assertions -&gt; 2312 tests / 4928 assertions (+24 / +70). Pre-existing 14E/5F count (per #1206) is unchanged. Legacy tools/test-all.sh: EXIT=0. Vitest: 64 files / 957 tests &mdash; unchanged. Coverage delta: includes/services/class-tc-excel-float-service.php from 0% -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.7.5</strong> &mdash; <?php _e('Test coverage (slice 82 &mdash; pin TC_Scroll_Buttons_Service at ~100%; CLOSES batch 10). Fifth and final slice of batch 10 (slices 78 to 82, v5.7.1 -&gt; v5.7.5 &mdash; all five small 0% services in this batch now pinned). New tests/GTScrollButtonsServiceTest.php (17 tests / 27 assertions) pins includes/services/class-tc-scroll-buttons-service.php (34 statements) from 0% -&gt; ~100% line + method coverage. TC_Scroll_Buttons_Service is the pure presenter behind the horizontal scroll navigation buttons that flank wide tables (#102) &mdash; the feature is opt-in per table via the show_scroll_buttons setting and surfaces left/right chevron buttons that are hidden by default and revealed by JS only when the table actually overflows its container. Two public static methods plus the DEFAULT_SCROLL_AMOUNT = 200 constant, all exercised. render_buttons() covered &mdash; default-parameter path (called with no arguments, falls through to the scroll_amount null-coalesce); empty-settings path (same fallback); explicit-amount honoured (350 round-trips into data-scroll-amount=350 on both buttons with the default 200 absent); zero-amount explicit-guard fallback to DEFAULT_SCROLL_AMOUNT (without the === 0 guard the buttons would emit data-scroll-amount=0 and JS could not scroll &mdash; pinned by an explicit assertion that 200 is restored when callers pass 0); negative-amount coercion via absint(-50) === 50 (input is normalised, not zeroed); numeric-string accepted (300 string -&gt; 300 via absint &mdash; common shape from settings JSON); non-numeric string falls back to default (absint(not-a-number) === 0 -&gt; explicit-guard restores 200). HTML structure pinned &mdash; both button elements emitted (exactly two via substr_count); both carry the gt-scroll-btn--hidden modifier class so JS owns the reveal contract; left button starts with the disabled attribute (scroll position is 0 at load &mdash; nothing to scroll back to) but the right button is NOT disabled (content overflows to the right when shown); the disabled marker appears between the left class and the right class in the rendered string (positional invariant); both buttons carry aria-label Scroll table left / aria-label Scroll table right for assistive tech; chevron icons (&amp;#8249; single left-pointing angle quotation, &amp;#8250; single right-pointing angle quotation) wrapped in span aria-hidden=true so screen readers rely on the aria-label instead of the glyph; both buttons declare type=button so they never submit a parent form when the scroll-buttons feature is rendered inside an entry-editing context. enqueue_assets() covered &mdash; registers the gt-scroll-buttons handle with jquery dependency and in_footer = true; src path round-trips through TC_PLUGIN_URL . assets/js/gt-scroll-buttons.js; both observable when the structured-capture shim wins the load race, gracefully degrading to handle-only assertion + smoke when a 1-arg sibling wp_enqueue_script shim won first. Strategy notes: pure presenter so no wpdb, no GFAPI, no inline-style enqueue. In-file shims declared under function_exists guards: absint (mirrors GTWooCommerceTest / GTBorderServiceTest); esc_attr__ (mirrors GTSkeletonServiceTest &mdash; bootstrap declares esc_html__ and esc_attr but not esc_attr__; pass-through htmlspecialchars matches WP semantics for an aria-label string); wp_enqueue_script (structured 5-arg capture into _gt_test_scroll_buttons_enqueued plus single-arg passthrough to _gt_test_enqueued_scripts for sibling-test contract compatibility &mdash; bootstrap tests/helpers/stubs.php declares a no-op variadic, GTFormRendererTest / GTMapTest declare 1-arg capture variants; whichever wins the first-load race owns the contract for the entire PHPUnit process so the test degrades to skip when only the handle list is populated). Patterns reused: slice 71 (GTSkeletonServiceTest &mdash; sibling pure presenter service test template), slice 80 (GTBorderServiceTest &mdash; sibling-collision contract documentation for shared shim keys). Shim suite: 2271 tests / 4831 assertions -&gt; 2288 tests / 4858 assertions (+17 / +27). Pre-existing 14E/5F count (per #1206) is unchanged. Legacy tools/test-all.sh: EXIT=0. Vitest: 64 files / 957 tests &mdash; unchanged. Coverage delta: includes/services/class-tc-scroll-buttons-service.php from 0% -&gt; ~100%. Batch 10 close-out: all five files in this batch &mdash; TC_Star_Rating_Service (slice 78), TC_Row_Grouping_Service (slice 79), TC_Border_Service (slice 80), TC_Wrap_Mode_Service (slice 81), TC_Scroll_Buttons_Service (slice 82) &mdash; now sit at ~100% line coverage. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.7.4</strong> &mdash; <?php _e('Test coverage (slice 81 &mdash; pin TC_Wrap_Mode_Service at ~100%). Fourth slice of batch 10. New tests/GTWrapModeServiceTest.php (21 tests / 42 assertions) pins includes/services/class-tc-wrap-mode-service.php (35 statements) from 0% -&gt; ~100% line + method coverage. TC_Wrap_Mode_Service is the pure-logic helper behind the per-column word-break / hyphenation control feature (#520) &mdash; it exposes the four supported wrap modes (default, break-word, hyphenate, nowrap) plus a sanitization layer for stored settings, the per-mode CSS declaration emitter consumed by the frontend renderer, and the BCP-47 lang resolver used by the hyphenate cell render path. Five public static methods plus the MODES constant, all exercised. get_modes() covered &mdash; returns the four canonical modes in the documented order; constant / getter parity verified so callers reading TC_Wrap_Mode_Service::MODES directly stay in sync with the getter. sanitize() covered &mdash; non-string fallback (null, 123, array, true all collapse to default); whitespace + case normalization via strtolower(trim(...)) (NOWRAP with leading/trailing spaces -&gt; nowrap, Break-Word -&gt; break-word, tab-HYPHENATE-newline -&gt; hyphenate); valid-mode passthrough for all four canonical values; unknown-string fallback to default (empty string, wrap, break_word with underscore). sanitize_map() covered &mdash; non-array guard returns empty array (null, scalar string, integer); default values are intentionally dropped to keep stored settings minimal (matches production comment); non-default values retained; mixed input prunes correctly (col_unknown -&gt; foobar sanitizes to default and is then dropped, demonstrating both layers compose); numeric column ids coerced to strings via the production (string) cast so the returned array keys are always strings even when callers pass integer ids. css_for_mode() covered &mdash; every switch arm exercised: break-word emits overflow-wrap:anywhere;word-break:break-word; hyphenate emits the five-declaration string with both hyphens:auto and the -webkit-hyphens:auto / -ms-hyphens:auto vendor prefixes (asserted independently so a prefix-stripping refactor would surface); nowrap emits white-space:nowrap;overflow:hidden;text-overflow:ellipsis; default and unknown modes both fall through the shared empty-string arm (the default case label sits below case default with no break, so unknown values cannot diverge from default &mdash; pinned by an explicit not-a-mode assertion). lang_for_mode() covered &mdash; non-hyphenate short-circuit returns empty string for all three other modes including the case where an explicit lang is provided (verifies the guard fires before the explicit-lang branch); explicit lang takes precedence over get_bloginfo when set to a non-empty string (get_bloginfo is asserted with times =&gt; 0 so the call would be flagged if the short-circuit regressed); explicit-empty-string fails the !== empty-string guard and falls through to get_bloginfo; get_bloginfo(language) returning fr-FR returns the locale string verbatim; get_bloginfo returning the empty string falls through to the final en fallback (so a misconfigured WordPress install never emits an empty lang attribute on hyphenated cells). Strategy notes: pure stateless service &mdash; no wpdb, no GFAPI, no inline-style enqueue, no hook registration. The only external collaborator is get_bloginfo(language) which is driven per-test via WP_Mock::userFunction (mirrors GTAutoImportTest and GTCloudStorageTest &mdash; no global shim, no sibling test-file collision risk). No process isolation needed since there are no class-existence branches or alias declarations. Patterns reused: slice 80 (GTBorderServiceTest &mdash; sibling pure-logic service test template, no enqueue path), slice 79 (GTRowGroupingServiceTest &mdash; sibling pure-logic service with WP_Mock::userFunction for external collaborators). Shim suite: 2250 tests / 4789 assertions -&gt; 2271 tests / 4831 assertions (+21 / +42). Pre-existing 14E/5F count (per #1206) is unchanged. Legacy tools/test-all.sh: EXIT=0. Vitest: 64 files / 957 tests &mdash; unchanged. Coverage delta: includes/services/class-tc-wrap-mode-service.php from 0% -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.7.3</strong> &mdash; <?php _e('Test coverage (slice 80 &mdash; pin TC_Border_Service at ~100%). Third slice of batch 10. New tests/GTBorderServiceTest.php (23 tests / 56 assertions) pins includes/services/class-tc-border-service.php (35 statements) from 0% -&gt; ~100% line + method coverage. TC_Border_Service is the pure CSS-emitter that generates scoped per-table border styles for four sections (row dividers, column dividers, outer border, header bottom border) and exposes four named presets (classic, rows_only, none, outer_only) consumed by the table-builder admin UI. Three public static methods plus the PRESETS constant, all exercised. get_preset_settings() covered &mdash; the four canonical presets round-trip with their expected enabled / color / width / radius fields (classic uses #dee2e6 on rows/cols/outer + #adb5bd on header bottom; rows_only enables only horizontal dividers + header; none zeroes every section; outer_only is the only preset with a non-zero border-radius of 4px); unknown preset names and the empty-string name both fall back to classic via the ?? null-coalesce. get_border_css() covered &mdash; every section has an enabled vs disabled branch, each one independently exercised. Row dividers: enabled emits border-bottom: {width}px solid {color}; disabled emits border-bottom: none; invalid hex (e.g. not-a-color) falls back to the section default #dee2e6 via the sanitize_hex_color() ?: short-circuit. Column dividers: enabled emits the body rule on td, th plus the :last-child reset that strips the trailing border on the rightmost cell; disabled emits a single border-right: none body rule with no :last-child selector. Outer border: enabled emits border: {w}px solid {c}; border-radius: {r}px with explicit radius; missing radius key falls through absint(null) -&gt; 0px; disabled emits border: none and suppresses the border-radius rule entirely. Header bottom border: enabled emits thead tr border-bottom with the header-specific default #adb5bd (different from the other three sections #dee2e6); invalid hex falls back to #adb5bd; disabled emits border-bottom: none. Scope-selector containment verified &mdash; every rule is prefixed with #gt-table-{id} so the CSS never bleeds to a sibling table on the same page. enqueue_inline_css() covered &mdash; happy path appends to the gt-frontend style handle; the none preset still emits non-empty CSS (every disabled branch writes a none rule) so the enqueue helper unconditionally fires wp_add_inline_style on realistic inputs; round-trip equivalence verified between enqueue_inline_css(id, settings) and get_border_css(id, settings). Strategy notes: pure CSS-emitter so no $wpdb, no GFAPI, no globals, no process isolation. In-file shims declared under function_exists guards: sanitize_hex_color (mirrors GTConditionalFormattingServiceTest &mdash; returns null for invalid input; passes through #xxx / #xxxxxx); absint (mirrors GTWooCommerceTest); wp_add_inline_style (mirrors GTSkeletonServiceTest capture shape). Because PHPUnit loads test files alphabetically, GTBorderServiceTest (B) loads before GTSkeletonServiceTest (S) and this file wins the wp_add_inline_style declaration race; the captured-entry write fans out into BOTH GLOBALS[_gt_test_border_inline_styles] and GLOBALS[_gt_test_skeleton_inline_styles] so the slice-71 contract stays green regardless of file-load order. Patterns reused: slice 71 (GTSkeletonServiceTest &mdash; sibling CSS-emitter service template, in-file wp_add_inline_style capture pattern), slice 42 (GTConditionalFormattingServiceTest &mdash; in-file sanitize_hex_color shim guard). Shim suite: 2227 tests / 4733 assertions -&gt; 2250 tests / 4789 assertions (+23 / +56). Pre-existing 14E/5F count (per #1206) is unchanged. Legacy tools/test-all.sh: EXIT=0 (523 files; 2 allowed-fail). Vitest: 64 files / 957 tests &mdash; unchanged. Coverage delta: includes/services/class-tc-border-service.php from 0% -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.7.2</strong> &mdash; <?php _e('Test coverage (slice 79 &mdash; pin TC_Row_Grouping_Service at ~100%). Second slice of batch 10. New tests/GTRowGroupingServiceTest.php (25 tests / 49 assertions) pins includes/services/class-tc-row-grouping-service.php (38 statements) from 0% -&gt; ~100% line + method coverage (36/36 lines, 7/7 methods, per isolated --coverage run). TC_Row_Grouping_Service is the pure-logic helper behind the DataTables RowGroup integration: it reads three group_by_* settings keys, buckets a flat row list into a value =&gt; rows[] map, serialises the DataTables rowGroup extension config as JSON, and renders a &lt;tr&gt; group-header row with sanitised label + colspan. Five public static methods plus one private column_index() helper, all exercised. is_enabled() covered &mdash; false when group_by_column is absent or empty; true when populated (the gate the rest of the rowGroup pipeline keys off). get_group_column() covered &mdash; absent setting returns empty string; sanitize_key lowercases + strips non-[a-z0-9_-] characters (Category_2 -&gt; category_2, Col-ID_1!@# -&gt; col-id_1). is_default_collapsed() covered &mdash; false when group_default_collapsed is absent or zero; true for explicit 1 and boolean true. group_rows() covered &mdash; empty input returns empty map; multi-row bucketing preserves insertion order within each group key; rows missing the column key bucket under empty-string key alongside rows with explicit empty value; non-string column values cast via (string) so integer 7 ends up under string key 7. get_datatables_config() covered &mdash; column_index found case (group_by_column category at index 1 in a 3-column array emits dataSrc: 1); not-found case falls back to 0 (the column_index private helper default-return branch); columns array entries without an id key never match; groupCollapsed propagates from is_default_collapsed; structural assertions on the JSON envelope (startClassName gt-row-group-header, endClassName empty); wp_json_encode round-trips back to valid PHP via json_decode. get_group_header_html() covered &mdash; &lt;tr class=gt-row-group-header data-group=...&gt;&lt;th colspan=N scope=colgroup role=rowheader&gt;...&lt;/th&gt;&lt;/tr&gt; structure; group_label_prefix concatenates with a single space (Category: + space + Electronics -&gt; Category: Electronics); empty prefix renders standalone; col_span clamping (zero and negative inputs collapse to colspan=1 via max(1, (int) col_span)); XSS protection &mdash; data-group runs through esc_attr and the body label through esc_html, so a payload like &quot;&gt;&lt;script&gt;x&lt;/script&gt; is fully escaped on both surfaces; default settings = [] argument renders cleanly. Private column_index() reached transitively via the get_datatables_config found / not-found / id-missing paths. Strategy notes: pure-logic service so no $wpdb, no GFAPI, no filters, no globals, no process isolation needed. Bootstrap declares esc_html / esc_attr / wp_json_encode but NOT sanitize_key; in-file shim under function_exists guard mirrors real WP (lowercase + strip non-[a-z0-9_-]). All assertions stay against the rendered HTML / decoded JSON since the sprintf template is a stable string literal in production. Patterns reused: slice 77 (GTMultiSortServiceTest &mdash; sibling service test file with the same sanitize_key shim guard), slice 78 (GTStarRatingServiceTest &mdash; pure-logic service template). Shim suite: 2202 tests / 4684 assertions -&gt; 2227 tests / 4733 assertions (+25 / +49). Pre-existing 14E/5F count (per #1206) is unchanged. Legacy tools/test-all.sh: EXIT=0 (523 files; 2 allowed-fail). Vitest: 64 files / 957 tests &mdash; unchanged. Coverage delta: includes/services/class-tc-row-grouping-service.php from 0% -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.7.1</strong> &mdash; <?php _e('Test coverage (slice 78 &mdash; pin TC_Star_Rating_Service at ~100%; opens batch 10). First slice of batch 10. New tests/GTStarRatingServiceTest.php (28 tests / 65 assertions) pins includes/services/class-tc-star-rating-service.php (51 statements) from 0% -&gt; ~100% line coverage (49/49 statements; 3/3 methods, per isolated --coverage run). TC_Star_Rating_Service is the pure-logic column-type renderer that converts a numeric rating value (e.g. 3.5) into filled / half / empty SVG stars with full accessibility support (role=img, aria-label, aria-hidden=true on inner SVGs) and a DataTables-compatible numeric sort value. Two public static methods plus one private star_svg() helper, all exercised. render() covered &mdash; wrapper span scaffolding (class=gt-star-rating, role=img, data-rating formatted to 2 decimals, data-max integer); default-max behaviour (omitting the max arg still emits data-max=5); aria-label sprintf path (4.0 out of 5 stars via number_format_i18n and max); full/half/empty branch boundary contract &mdash; diff &gt;= 1.0 -&gt; full (so a rating of exactly 1.0 with max 2 yields 1 full + 1 empty, no half), 0.5 &lt;= diff &lt; 1.0 -&gt; half (rating 0.5 + max 1 yields exactly one half star), diff &lt; 0.5 -&gt; empty (rating 0.49 + max 1 yields exactly one empty); mixed 3.5/5 produces 3 full + 1 half + 1 empty; 5/5 produces 5 full (no half/empty); 0/5 produces 5 empty (no full/half); clamping &mdash; negative rating (-3) clamps to 0 (data-rating=0.00, all empty), rating above max (12.7 with max 5) clamps to max (data-rating=5.00, all full), max=0 clamps up to 1 via max(1, (int) max) so data-max=1; input coercion &mdash; numeric string 4.5 parses through floatval, non-numeric abc collapses to 0; custom options &mdash; default color=#f5a623, empty_color=#d3d3d3, size=1em flow into SVG fill / width / height attributes; overrides (#ff0000, #cccccc, 2rem) replace the defaults; half-star SVG emits the clipPath id=gt-half-clip with both fill colours present (one on the unclipped path, one on the clipped one) so the left-half-filled visual is preserved; esc_attr pass-through verified by injecting a script-tag payload into the color option and asserting the literal does not appear (htmlspecialchars-encoded form does); data-rating formats to 2 decimal places (2.7 -&gt; data-rating=2.70); SVG-element count equals max so the SVG-per-star contract holds at any max (e.g. 10). get_sort_value() covered &mdash; returns float for int (4 -&gt; 4.0), float (3.5 -&gt; 3.5), numeric string (2.25 -&gt; 2.25); returns 0.0 for non-numeric string, empty string, and null (the three floatval falsy boundaries). Private star_svg() reached transitively via the three render branches. Strategy notes: pure logic file &mdash; no $wpdb, no GFAPI, no globals, no process isolation needed. Bootstrap declares esc_attr (htmlspecialchars-backed) but neither esc_attr__ nor number_format_i18n; both are declared in-file under function_exists guards &mdash; esc_attr__ as a htmlspecialchars pass-through (matches WP aria-label semantics), number_format_i18n as a plain number_format wrapper (deterministic locale-free assertions). All assertions stay against the rendered HTML string since SVG markup and clip-path are stable string literals in production. Patterns reused: slice 71 (GTSkeletonServiceTest &mdash; pure visual-presenter test layout, in-file esc_attr__ shim), slice 77 (GTMultiSortServiceTest &mdash; pure-logic service template). Shim suite: 2174 tests / 4619 assertions -&gt; 2202 tests / 4684 assertions (+28 / +65). Pre-existing 14E/5F count (per #1206) is unchanged. Legacy tools/test-all.sh: EXIT=0 (523 files; 2 allowed-fail). Vitest: 64 files / 957 tests &mdash; unchanged. Coverage delta: includes/services/class-tc-star-rating-service.php from 0% -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.7.0</strong> &mdash; <?php _e('Test coverage (slice 77 &mdash; pin TC_Multi_Sort_Service at ~100%; CLOSES batch 9). Fifth and final slice of batch 9 (slices 73 to 77, v5.6.6 -&gt; v5.7.0 &mdash; all five small 0% files from this batch now pinned). New tests/GTMultiSortServiceTest.php (30 tests / 35 assertions) pins includes/services/class-tc-multi-sort-service.php (51 statements) from 0% -&gt; ~100% line coverage (49/49 statements; 6/6 methods, per isolated --coverage run). TC_Multi_Sort_Service is the pure-logic helper that powers shift-click multi-column sort (max 3 simultaneous columns, MAX_SORT_COLUMNS contract). Five static methods plus one private helper, no wpdb / no filters / no hooks. is_enabled() covered: default-true when the enable_multi_sort setting is absent (existing tables get the feature automatically); explicit true / false; truthy-string passthrough (yes -&gt; true via (bool) cast); zero-string -&gt; false (PHPs one falsy non-empty string). validate_sort_stack() covered: clean entry passthrough; non-array entry skip (not-an-array, 42); empty column_id skip (empty string, missing key); invalid direction defaults to asc (sideways, empty string, missing); lowercase normalization (DESC -&gt; desc, Asc -&gt; asc); sanitize_key strips uppercase + non-[a-z0-9_-] chars (Field 1! -&gt; field1); cap at MAX_SORT_COLUMNS (3); empty input returns empty. get_datatables_order() covered: column_id resolves to index in a multi-column stack ([[1,asc],[3,desc]]); unknown column skipped via the column_index() false-return; empty stack returns empty-array JSON; empty columns array returns empty-array JSON (every column_id fails to resolve); the default [] columns argument matches explicit-empty (signature default branch). build_order_by() covered: whitelisted columns emit ORDER BY field_1 ASC, field_2 DESC; non-whitelisted (including SQL-injection payloads like evil; DROP TABLE wp_users;--) skipped; empty stack returns empty string; whitelist empty / all-entries-skipped early-return; DESC normalization to upper; invalid direction defaults to ASC via the ternary else branch. apply_to_query() covered: empty stack pass-through (existing args returned unchanged); primary-only sorting subarray with key / direction / is_numeric=&gt;false; DESC vs ASC normalization; invalid direction defaults to ASC; defence-in-depth sanitize_key re-application on the primary column_id (Field 7! -&gt; field7). Constant guard pins MAX_SORT_COLUMNS = 3. Private column_index() reached transitively via get_datatables_order found/not-found cases. Strategy notes: pure-logic service so no wpdb mock, no filters, no hooks &mdash; direct input/output assertions. In-file sanitize_key shim under function_exists guard (bootstrap declares none); wp_json_encode already shimmed in tests/bootstrap.php (slice 10 stub). Patterns reused: slice 67 (GTValidationServiceTest &mdash; pure-logic service template), slice 76 (GTWritebackServiceTest &mdash; service-style layout + in-file shim guards). Shim suite: 2144 tests / 4584 assertions -&gt; 2174 tests / 4619 assertions (+30 / +35). Pre-existing 14E/5F count (per #1206) is unchanged. Legacy tools/test-all.sh: EXIT=0 (523 files; 2 allowed-fail). Vitest: 64 files / 957 tests &mdash; unchanged. Coverage delta: includes/services/class-tc-multi-sort-service.php from 0% -&gt; ~100%. Batch 9 close-out: all five files in this batch &mdash; TC_Global_Search_Service (slice 73), TC_Migration_Tool (slice 74), TC_Frontend_Editing_Panel (slice 75), TC_Writeback_Service (slice 76), TC_Multi_Sort_Service (slice 77) &mdash; now sit at ~95-100% line coverage. Leading-digit bump-at-9 increment to v5.7.0 (next would have been 5.6.10 which is illegal under the rule). No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.6.9</strong> &mdash; <?php _e('Test coverage (slice 76 &mdash; pin TC_Writeback_Service at ~100%). Fourth slice of batch 9. New tests/GTWritebackServiceTest.php (28 tests / 62 assertions) pins includes/services/class-tc-writeback-service.php (54 statements) from 0% -&gt; ~100% line coverage. TC_Writeback_Service is the stateless static-method service that persists frontend inline edits back to an external MySQL database when the per-table write-back option is enabled (issue #86). Four public static methods. is_enabled(array settings) covered &mdash; truthy / falsy / missing variants of the writeback_enabled setting (off by default to preserve existing tables). can_writeback(array settings) covered &mdash; disabled-table branch returns a writeback_disabled WP_Error with the human-readable not enabled message; capability-missing returns writeback_forbidden; default capability fallback is edit_posts when writeback_capability is absent; explicit writeback_capability =&gt; manage_options is honored; user without the configured cap returns writeback_forbidden; empty-string capability triggers the inner empty() fallback to edit_posts; sanitize_key normalizes input (Edit_Posts! -&gt; edit_posts) so a sloppy admin setting still resolves to a real cap; both error paths carry user-facing messages (not enabled, permission). update_row(wpdb, table, pk_col, pk_val, field_col, new_val) covered &mdash; happy path returns true; prepared SQL contains backtick-escaped identifiers (UPDATE backtick-people-backtick, SET backtick-email-backtick, WHERE backtick-id-backtick); literal backticks inside identifier names are doubled per MySQL convention (tbl-backtick-name becomes tbl-double-backtick-name); prepared values flow through wpdb-&gt;prepare() (no interpolation); db-&gt;query() returning false produces a writeback_db_error WP_Error whose message includes db-&gt;last_error; the gravity_tables_writeback_before filter receives the full context array (table_name, primary_key_col, primary_key_value, field_col, new_value) and the write proceeds when it returns truthy; the same filter returning false short-circuits with a writeback_rejected WP_Error AND the DB is not touched (zero entries in db-&gt;queried); the gravity_tables_writeback_after action fires on success with the same context payload and does NOT fire when the DB write fails. detect_conflict(wpdb, table, pk_col, pk_val, field_col, expected_val) covered &mdash; missing row (db-&gt;get_var returns null) is treated as a conflict (row was deleted externally); value-differs returns true; value-matches returns false; both sides are cast via (string) so an int DB value of 42 matched against a string 42 expected value is NOT a conflict; prepared SELECT SQL carries backtick-escaped identifiers + LIMIT 1; backtick-doubling applies to identifiers passed to detect_conflict too. Strategy notes: tests use a local TC_Test_Writeback_WPDB_Stub extending the placeholder wpdb declaration (shared with GTExceptionsTest) so the production wpdb typehint binds structurally. The stub records the SQL passed to query() / get_var() so tests can assert on the literal escaped string instead of mocking individual call expectations. The before-filter rejection branch is driven via GLOBALS[_gt_test_filters][gravity_tables_writeback_before] = false (bootstrap apply_filters seam); the after-action firing is observed via the bootstrap-shared GLOBALS[gt_test_actions] registry that slice 17 pinned. In-file shims under function_exists guards for current_user_can, sanitize_key, __ (bootstrap declares none); the wpdb placeholder class is also declared under a class_exists guard so test-file load order does not matter (GTExceptionsTest declares the same shape). Patterns reused: slice 63 (GTEntryServiceTest &mdash; capability registry pattern), slice 27 (GTExceptionsTest &mdash; structural wpdb placeholder), slice 73 (GTGlobalSearchServiceTest &mdash; service-style test layout). Shim suite: 2116 tests / 4522 assertions -&gt; 2144 tests / 4584 assertions (+28 / +62). Pre-existing 14E/5F count (per #1206) is unchanged. Legacy tools/test-all.sh: EXIT=0 (523 files; 2 allowed-fail). Vitest: 64 files / 957 tests &mdash; unchanged. Coverage delta: includes/services/class-tc-writeback-service.php from 0% -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.6.8</strong> &mdash; <?php _e('Test coverage (slice 75 &mdash; pin TC_Frontend_Editing_Panel at ~100%). Third slice of batch 9. New tests/GTFrontendEditingPanelTest.php (25 tests / 51 assertions) pins includes/admin/class-tc-frontend-editing-panel.php (54 statements) from 0% -&gt; ~100% line coverage. TC_Frontend_Editing_Panel renders the frontend-editing toggle panel in the table-builder admin UI &mdash; the top-level surface that exposes enable_frontend_editing (a checkbox), the no-roles warning when editing is enabled but user_roles_can_edit is empty, the roles-summary block that hides via inline display:none when editing is disabled, and the short ON/OFF status string consumed by the builder summary header. Public surface: render(array settings): void outputs the full settings panel; render_summary_status(array settings): string returns the short header HTML; one private roles_configured() helper exercised via Reflection for branch-coverage completeness (the method is currently unreferenced from production but ships in the file). render() covered: always-rendered scaffold (id=gt-frontend-editing-panel, class=gt-panel gt-frontend-editing-panel, Frontend Editing title, the gt_settings[enable_frontend_editing] checkbox with value=1, the help blurb under gt-help-text); enabled-vs-disabled branch on the checked() output (true -&gt; checked attribute present, false -&gt; absent, missing key -&gt; absent via empty(), explicit 0 -&gt; absent); no-roles warning branch &mdash; emitted only when enable_frontend_editing is truthy AND user_roles_can_edit is empty (or missing via null-coalesce to empty array), suppressed when disabled, suppressed when roles are populated; warning markup carries notice notice-warning, role=alert, the No roles are configured copy, and a link to admin.php?page=gravity-tables&amp;tab=settings#frontend-editing labeled Settings page; roles-summary block carries an inline display:none when disabled and no inline style when enabled, the Roles that can edit: label plus comma-joined role list (administrator, editor, driver), the Configure link target, and the defensive esc_html map across roles (a script-tag role name renders as &amp;lt;script&amp;gt; not the live tag); the inner roles list paragraph is correctly suppressed when user_roles_can_edit is empty even though the outer summary wrapper is visible. render_summary_status() covered: returns a string; enable_frontend_editing true -&gt; gt-status--on span containing ON; false (or missing) -&gt; gt-status--off containing OFF; always carries the gt-summary-configure-link anchor pointing at the same settings tab anchor; always wraps with gt-summary-item gt-summary-frontend-editing and the Frontend editing label. Private roles_configured() covered via ReflectionMethod for both truthy and falsy inputs; PHP 8.5 no longer requires setAccessible(true) so the deprecation guard is dropped. Strategy notes: render() output captured via ob_start / ob_get_clean and asserted on substrings the user actually sees (rather than full-string equality &mdash; the panel mixes the checked() echo with literal HTML which is fragile to reformat); printf with esc_html__ translator string on the no-roles warning so the link anchor must come through as inline HTML &mdash; hence the substring assertion on the admin.php?... fragment. In-file shims declared under function_exists guards (bootstrap declares none): admin_url returns the conventional http://example.test/wp-admin/{path}; esc_html_e echoes the htmlspecialchars-encoded text; esc_html__ returns it; _e echoes raw; checked echoes the attribute markup when args match (single-bool overload matches the panel call site); wp_kses_post passthrough; __ identity. Pattern reused from slice 53 (GTImportTest admin_url + esc_html_e shape) and slice 74 (GTMigrationToolTest class layout + Mockery::close tearDown). Shim suite: 2091 tests / 4471 assertions -&gt; 2116 tests / 4522 assertions (+25 / +51). Pre-existing 14E/5F count (per #1206) is unchanged. Legacy tools/test-all.sh: EXIT=0 (523 files; 2 allowed-fail). Vitest: 64 files / 957 tests &mdash; unchanged. Coverage delta: includes/admin/class-tc-frontend-editing-panel.php from 0% -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.6.7</strong> &mdash; <?php _e('Test coverage (slice 74 &mdash; pin TC_Migration_Tool at ~100%). Second slice of batch 9. New tests/GTMigrationToolTest.php (21 tests / 43 assertions) pins includes/class-tc-migration-tool.php (56 statements) from 0% -&gt; ~100% line coverage. TC_Migration_Tool migrates legacy tables (those whose settings use the old selected_fields flat-array format) to the modern columns array format introduced for #214. Three public static methods. is_legacy_table(array settings): bool covered &mdash; selected_fields set + no columns key -&gt; true; selected_fields set + empty columns -&gt; true; selected_fields set + populated columns -&gt; false; columns-only (no selected_fields) -&gt; false; selected_fields set but not an array -&gt; false; empty settings -&gt; false. get_legacy_tables(): int[] covered &mdash; empty get_results -&gt; empty array; null get_results coerced via the (array) cast (documents the defensive cast against $wpdb returning null); mixed legacy/modern row filtering preserves only the legacy ids; non-array settings string returned by maybe_unserialize is skipped via the is_array guard without crashing; missing settings column coerces to the empty string via null-coalesce and skips; id is cast to int. migrate_table(int table_id): array{success, message} covered &mdash; row not found (both null and empty-array get_row returns fall through the !$row falsy guard &mdash; documents the PHP boundary that an empty array hits the Table not found. branch the same way null does); settings junk (non-array unserialize result) is coerced to empty array and treated as already-current; legitimate modern table -&gt; Table is already in the current format.; wpdb-&gt;update returning false -&gt; Database update failed.; happy path covers the wpdb-&gt;update call shape (table name from {prefix}gravity_tables, where = id =&gt; table_id); selected_fields -&gt; columns conversion casts each field_id to string with empty label; legacy_backup_settings snapshot captures pre-migration state (NO columns, NO selected_fields_migrated flag) and preserves arbitrary custom meta keys; selected_fields_migrated flag set to true; selected_fields retained for backward compat; success message includes the table id (Table #7 successfully migrated) and the legacy_backup_settings reference. Strategy notes: Mockery-mocked $wpdb seam shape (slice 41 / GTConfigPortServiceTest pattern). In-file shims under function_exists guards for __, maybe_unserialize, maybe_serialize (bootstrap declares none); maybe_unserialize uses unserialize with allowed_classes =&gt; false to match WP semantics. Patterns reused: slice 41 (GTConfigPortServiceTest &mdash; Mockery $wpdb seam), slice 35 (GTAutoloadManagerTest &mdash; static-helper class), slice 54 (GTAutoImportTest &mdash; tool-class). Shim suite: 2070 tests / 4428 assertions -&gt; 2091 tests / 4471 assertions (+21 / +43). Pre-existing 14E/5F count (per #1206) is unchanged. Legacy tools/test-all.sh: EXIT=0 (523 files; 2 allowed-fail). Vitest: 64 files / 957 tests &mdash; unchanged. Coverage delta: includes/class-tc-migration-tool.php from 0% -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.6.6</strong> &mdash; <?php _e('Test coverage (slice 73 &mdash; pin TC_Global_Search_Service at ~100%; opens batch 9). First slice of batch 9. New tests/GTGlobalSearchServiceTest.php (59 tests / 80 assertions) pins includes/services/class-tc-global-search-service.php (62 statements) from 0% -&gt; ~100% line coverage. TC_Global_Search_Service renders the [gravity_global_search] shortcode &mdash; a cross-table search widget that wires one input to many DataTables on the same page, with 300 ms debounce, an accessible label / aria-label / clear button, and optional ?gt_search= URL sync. Surface covered: singleton (get_instance() returns same instance across calls); constructor wiring (add_shortcode(gravity_global_search, ...) registered, single entry under repeated get_instance()); URL_PARAM constant pinned at gt_search; DEBOUNCE_MS constant pinned at 300. get_table_ids() parser &mdash; the all keyword resolves to an empty array (signal to JS to target every DataTable), empty string and whitespace-only fall through the same branch, single numeric id, comma-separated 1,2,3, whitespace tolerance per id, int coercion (each output is is_int), array_filter drops zero-after-cast values (abc -&gt; 0 filtered), array_values reindex when interior elements drop. get_widget_html() covered &mdash; widget id on wrapper, gt-global-search-wrap / inner / label / input / clear class scaffolding, label[for] wired to input id ({widget_id}-input), clear button id ({widget_id}-clear), default Search tables label + Search&hellip; placeholder, custom label and placeholder overrides, esc_html escape on label (XSS payload script-open tag rendered as &amp;lt;script&amp;gt;), esc_attr escape on placeholder attribute (double quote round-trip as &amp;quot;), aria-label on input, type=search and autocomplete=off defensive form attrs, initial hidden attribute on the clear button (only revealed via JS when the search term is non-empty), Clear search aria-label on the clear button. get_inline_script() covered &mdash; widget id literal substitution, table ids JSON-encoded array (non-empty + empty), default debounce 300 from the DEBOUNCE_MS constant, custom debounce 500, negative-debounce clamp to zero via max(0, ...), url_sync default false / explicit true, URL_PARAM constant substitution, IIFE wrapper, use strict declaration, DataTables call dt.search(term).draw(), gt-table-{id} prefix for the specific-id branch, $.fn.DataTable.tables lookup for the all branch, debounce pattern (clearTimeout + setTimeout), keyup listener on the input, clear-button click handler (input value reset, focus), URLSearchParams + history.pushState for the url-sync branch, applySearch(initial) prefill from the URL parameter on load. render_shortcode() end-to-end &mdash; widget HTML emits before the inline script tag, wp_unique_id drives a gt-global-search-{n} id that increments per call, default all tables resolves to empty tableIds JSON, comma-separated tables attr 4,8,15 flows through to the script, custom debounce attr 750, negative debounce attr clamps to 0, url_sync true / false round-trip through FILTER_VALIDATE_BOOLEAN, custom label and placeholder pass through, sanitize_text_field strips script tags from the tables attribute before parsing. In-file shims required (bootstrap declares none): shortcode_atts, add_shortcode, wp_unique_id, sanitize_text_field, __, esc_attr__. Singleton reset via Reflection between tests so the add_shortcode capture is observable per-test (slice 44 / GTMapTest pattern). Patterns reused: slice 44 (GTMapTest &mdash; singleton + add_shortcode capture seam), slice 71 (GTSkeletonServiceTest &mdash; pure presenter assertions, in-file esc_attr__ shim), slice 34 (GTCollapsibleServiceTest &mdash; pure static-helper assertions). Shim suite: 2011 tests / 4348 assertions -&gt; 2070 tests / 4428 assertions (+59 / +80). Pre-existing 14E/5F count (per #1206) is unchanged. Legacy tools/test-all.sh: EXIT=0 (64 vitest files / 957 tests). Coverage delta: includes/services/class-tc-global-search-service.php from 0% -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.6.5</strong> &mdash; <?php _e('Test coverage (slice 72 &mdash; pin TC_Date_Service at ~100%; CLOSES batch 8). Fifth and final slice of batch 8 (slices 68 to 72, v5.6.1 to v5.6.5 &mdash; all five small 0% services in includes/services/ now pinned). New tests/GTDateServiceTest.php (49 tests / 66 assertions) pins includes/services/class-tc-date-service.php (63 statements) from 0% -&gt; ~100% line coverage (61/61 statements; 4/4 methods, per isolated --coverage run). TC_Date_Service is the small format-translation helper called by class-tc-ajax.php line 3361 (per-field date metadata for the frontend) and class-tc-entry-service.php line 699 (sort-aware date format for STR_TO_DATE). Three pure-string transforms plus one lookup orchestrator. phpToMysqlFormat() covered: every key in the strtr map (Y -&gt; %Y, y -&gt; %y, m -&gt; %m, n -&gt; %c, d -&gt; %d, j -&gt; %e); separator passthrough (/, -, .); composed formats (m/d/Y -&gt; %m/%d/%Y, Y-m-d -&gt; %Y-%m-%d, d.m.Y -&gt; %d.%m.%Y); unmapped chars left untouched; empty-string input. gravityFormsToPhpFormat() covered: every canonical GF key (mdy -&gt; m/d/Y, dmy -&gt; d/m/Y, ymd -&gt; Y-m-d); separator variants (ymd_slash, ymd_dash, ymd_dot, mdy_dash, dmy_dash, dmy_dot, mdy_dot); uppercase-Y aliases (mdY, dmY, Ymd); long-form aliases (mm/dd/yyyy, dd/mm/yyyy, yyyy-mm-dd); unknown-key and empty-string fall back to m/d/Y via the null-coalesce default. getFieldDateFormat() covered: GFAPI happy path with matching date-type field (source gravity_forms, php_format, mysql_format, gf_format keys all present); strval() field-id comparison (numeric 12 matches string &quot;12&quot;); missing dateFormat property null-coalesces to mdy default; non-date field type skipped by the loop; mismatched field id skipped; get_form() returns false -&gt; plugin-settings fallback; form array without fields key -&gt; plugin-settings fallback; class_exists(GFAPI) returns false (via process isolation so prior Mockery alias registrations from GTEntryServiceTest / GTChartTest / GTFormRendererTest do not leak) -&gt; plugin-settings fallback. phpToJavascriptFormat() covered: every key in the strtr map (Y -&gt; YYYY, y -&gt; YY, m -&gt; MM, n -&gt; M, d -&gt; DD, j -&gt; D); separator passthrough including - which is NOT in the map but strtr leaves untouched (Y-m-d -&gt; YYYY-MM-DD); composed formats; unmapped chars passthrough; empty-string input. Strategy notes: GTAdminLoggerSeamTest (alphabetically earlier in the shim run) declares the first get_option shim and wins the function-redeclaration race, so the plugin-settings fallback always lands on the hard m/d/Y default in the shim env. The per-test gt_settings date_format override path is therefore not reachable from this test file, but the fallback branch itself (assignment of source = plugin_settings and the phpToMysqlFormat derivation) still reports as covered. Patterns reused: slice 71 (pure-helper test shape, no $wpdb); slice 63 (Mockery alias for GFAPI get_form happy paths); slice 51 (process isolation + preserveGlobalState disabled for the GFAPI-class-missing branch). Shim suite: 1962 tests / 4282 assertions -&gt; 2011 tests / 4348 assertions (+49 / +66). Pre-existing 14E/5F count (per #1206) is unchanged. Legacy 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/services/class-tc-date-service.php from 0% -&gt; ~100%. Batch 8 close-out: all five small 0% services in includes/services/ (TC_Error_Handler, TC_Typography_Service, TC_Custom_CSS_Writer, TC_Skeleton_Service, TC_Date_Service) now sit at ~95-100% line coverage. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.6.4</strong> &mdash; <?php _e('Test coverage (slice 71 &mdash; pin TC_Skeleton_Service at ~100%). Fourth slice of batch 8. New tests/GTSkeletonServiceTest.php (37 tests / 58 assertions) pins includes/services/class-tc-skeleton-service.php (64 statements) from 0% -&gt; ~100% line coverage (62/62 statements; 5/5 methods, per isolated --coverage run). TC_Skeleton_Service is a pure presenter for the table-builder loading-state animation: CSS-only shimmer placeholder, spinner, or none. All public methods are static and pure &mdash; no $wpdb, no globals beyond the single wp_add_inline_style enqueue path. Surface covered: get_animation_style() &mdash; default to skeleton when key missing; explicit skeleton / spinner / none passthrough; unknown-value (fancy, empty-string, bogus) fallback to skeleton. is_enabled() &mdash; true for skeleton (including the default), spinner, and unknown-fallback; false only when style is explicitly none. get_html() &mdash; none early-exit returns empty string; spinner branch emits &lt;div id=gt-skeleton-{id} class=gt-skeleton gt-skeleton--spinner role=status aria-label=...&gt; with the gt-spinner inner div and no skeleton table; skeleton (shimmer) branch emits the gt-skeleton-table with thead / tbody and a gt-skeleton-bar span inside every th / td. Column-count contract: missing columns defaults to 4; columns=&gt;[] also defaults to 4 (the ?:4 fallback fires when count([]) is falsy); explicit column array yields exactly that many th cells; rows-per-page 0 clamps to 1. Row-count contract: missing rows_per_page AND missing default_per_page defaults to 10 rows; rows_per_page wins over default_per_page; default_per_page is the documented secondary fallback. get_css() &mdash; none early-exit returns empty string; scoped @keyframes gt-shimmer-{id} plus #gt-skeleton-{id} selectors prevent cross-table style leak; scoped @keyframes gt-spin-{id} for the spinner branch; row-height regex accepts px / em / rem / vh (64px, 3em, 2.5rem, 5vh all pass through verbatim); rejects bare numbers (48), unsupported units (12pt), and XSS payloads (expression(alert(1))) &mdash; all fall back to the 48px default; @media (prefers-reduced-motion: reduce) guard is always present so users with the OS preference see no animation; (int) cast on table_id (passing 0 produces #gt-skeleton-0 and @keyframes gt-shimmer-0). enqueue_css() &mdash; passes the generated CSS string to wp_add_inline_style(gt-frontend, css); no-ops (no wp_add_inline_style call) when style is none because get_css returns the empty string. Shims declared in-file: esc_attr__ (bootstrap declares esc_attr and esc_html__ but not this one &mdash; pass-through htmlspecialchars); wp_add_inline_style (captures (handle, css) pairs into GLOBALS[_gt_test_skeleton_inline_styles] for assertion; no other test file declares this shim so we own the contract). Shim suite: 1925 tests / 4224 assertions -&gt; 1962 tests / 4282 assertions (+37 / +58). Pre-existing 14E/5F count (per #1206) is unchanged. Legacy 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/services/class-tc-skeleton-service.php from 0% -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.6.3</strong> &mdash; <?php _e('Test coverage (slice 70 &mdash; pin TC_Custom_CSS_Writer at ~100%). Third slice of batch 8. New tests/GTCustomCssWriterTest.php (20 tests / 47 assertions) pins includes/services/class-tc-custom-css-writer.php (60 statements) from 0% -&gt; ~95% line coverage (57/60 statements). Surface covered: write_css_file() &mdash; filesystem-init failure surfaces the WP_Filesystem could not be initialised notice; first-create path dispatches both the gravity-tables/ directory mkdir (with FS_CHMOD_DIR) and the Silence is golden index.php drop-in; existing-directory branch skips mkdir AND skips the index drop-in; mkdir failure registers the Cannot create CSS directory notice with the path interpolated; put_contents failure registers the Cannot write CSS file notice with the per-table filename; happy path emits table-{id}.css with the verbatim CSS content; success clears any stale gt_css_write_error option; table_id is cast to int for filename construction. get_css_url() &mdash; null when the file does not exist; full public URL (baseurl/gravity-tables/table-{id}.css) when present. delete_css_file() &mdash; clean short-circuit when init_filesystem fails; deletes when the file exists; no-ops when it does not. maybe_show_admin_notice() &mdash; early-returns on empty option; renders the dismissible-warning markup with the prefix; HTML-escapes stored error messages (XSS guard). init_filesystem() reuse short-circuit when wp_filesystem is already a WP_Filesystem_Base. register_notice() &mdash; persists the option AND calls add_settings_error(gt_custom_css, gt_css_write_failed, msg, warning) when is_admin() is true; non-admin context skips the add_settings_error call but still persists the option. Strategy: pre-declared WP_Filesystem_Base plus a TC_Test_WP_Filesystem double that records mkdir / put_contents / delete calls and lets each test dial in success/failure return values; pre-declared WP_Filesystem() function shim that wires the double into GLOBALS[wp_filesystem] (matches production init_filesystem() contract); tests flip GLOBALS[_gt_test_wp_filesystem_init_returns] to exercise the init-failure branch. WP_Mock::userFunction (Patchwork) overrides the suite-wide first-declared shim winners for get_option / update_option / delete_option / add_settings_error / is_admin / wp_normalize_path / trailingslashit / __ / esc_html__ &mdash; same pattern as GTAiSettingsEncryptionTest. wp_upload_dir is declared in tests/bootstrap.php BEFORE Patchwork bootstraps so WP_Mock::userFunction throws DefinedTooEarly; the test basedir routes through the bootstrap seam GLOBALS[_gt_test_upload_basedir] instead. Uncovered: ABSPATH exit guard (2 lines), the wp_upload_dir error-branch (2 lines, unreachable through the seam), and the lazy require_once ABSPATH . wp-admin/includes/file.php (1 line, dead under the pre-declared WP_Filesystem shim). Shim suite: 1905 tests / 4177 assertions -&gt; 1925 tests / 4224 assertions (+20 / +47). Pre-existing 14E/5F count (per #1206) is unchanged. Legacy 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/services/class-tc-custom-css-writer.php from 0% -&gt; ~95%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.6.2</strong> &mdash; <?php _e('Test coverage (slice 69 &mdash; pin TC_Typography_Service at ~100%). Second slice of batch 8. New tests/GTTypographyServiceTest.php (43 tests / 68 assertions) pins includes/services/class-tc-typography-service.php (70 statements) from 0% -&gt; ~97% line coverage (66/68 statements; 6/7 methods). Surface covered: generate_css() both guard branches (empty typography array, empty selector); wrapper-level rules (font_family, line_height) and the combined-prop single-block emission; selector escaping via the bootstrap esc_attr shim; header (th) rules (header_font_size, header_font_weight, header_color) plus combined-prop block; body (td) rules (body_font_size, body_font_weight, body_color) plus combined-prop block; emit-order contract (wrapper -&gt; th -&gt; td); the four private sanitizers (sanitize_font_family, sanitize_dimension, sanitize_font_weight, sanitize_color) exercised through generate_css with known-dirty inputs (HTML tags stripped, quotes and hyphens preserved, unit lowercasing, JS payloads stripped, hex/rgb/named-color passthrough). maybe_enqueue_google_font() &mdash; empty-family guard; all 12 system-font early-returns via data provider (inherit, system-ui, -apple-system, sans-serif, serif, monospace, Arial, Georgia, Verdana, Tahoma, Times New Roman, Courier New); already-enqueued short-circuit; canonical Google Fonts URL emission with family=...wght@400;600;700 + display=swap; space-to-plus encoding in family param; the #600 per-source CDN opt-out filter (gt_disable_third_party_cdn_google_fonts); the #600 global CDN kill-switch (gt_disable_third_party_cdn); the gt_google_fonts_url_base self-host filter (vanilla override, trailing-? rtrim case, and base-with-existing-query ampersand-append case). reset_to_defaults() full-shape contract. Strategy notes: GTFormRendererTest.php (loaded alphabetically first) wins the if (!function_exists(wp_enqueue_style)) race with a $handle-only stub, so observation flips to the underscored $GLOBALS[_gt_test_enqueued_styles] handle list; URL-shape assertions use a projectEnqueuedUrl() helper that registers a pass-through gt_google_fonts_url_base filter callback and reconstructs the URL the service is about to enqueue (since $src is not observable through the winning shim); includes/helpers-cdn.php is pre-loaded so the production file lazy require_once short-circuits and the per-source opt-out branch is exercisable without redeclaring the helper. Uncovered: the function_exists(wp_enqueue_style) early-return guard and the lazy require_once of helpers-cdn.php &mdash; both unreachable in the PHPUnit shim env. Shim suite: 1862 tests / 4109 assertions -&gt; 1905 tests / 4177 assertions (+43 / +68). Pre-existing 14E/5F count (per #1206) is unchanged. Legacy 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/services/class-tc-typography-service.php from 0% -&gt; ~97%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.6.1</strong> &mdash; <?php _e('Test coverage (slice 68 &mdash; pin TC_Error_Handler at ~100%; opens batch 8). First slice of batch 8. New tests/GTErrorHandlerTest.php (39 tests / 85 assertions) pins includes/services/class-tc-error-handler.php (84 statements) from 0% -&gt; ~98% line coverage. Surface covered: constructor (logFile under WP_CONTENT_DIR, debugMode from WP_DEBUG); logError (gravity_tables_error action payload shape, default ctx, $_SERVER reads and missing-key tolerance, validation-errors append for TC_Validation_Exception, file-write opt-in under debugMode via private-property reflection, silent catch on unwritable target); handleError (all five match arms &mdash; validation / permission / database / invalid-argument / default &mdash; plus the debug-mode raw-message branch); handleAjaxError (payload shape via the slice 20 wp_send_json_* throw seam so the never-return contract surfaces as a catchable RuntimeException; debug payload trace depth cap at 5; logError runs before the JSON response); addAdminNotice (admin_notices callback registration, HTML output, XSS escape via esc_html, default error type); clearErrorLog (file-missing returns true, file-present unlinks); getRecentErrors (empty when missing, last-N order, default 50-line window, skips empty lines); validateSetup (status / issues shape, missing WP_DEBUG_LOG flag, unwritable-dir flag via a 0555 fixture dir with chmod-back-and-rmdir in finally). Uncovered: the ABSPATH exit guard plus two defensive branches (file() returning false and disk_free_space below 10MB) that practical filesystems cannot trigger &mdash; real coverage is ~100%. Patterns reused: slice 34 (pure-service Reflection template), slice 20 throw seam, slice 27 (GTExceptionsTest WP_DEBUG-off shape assertions). Also closes #1193 by adding TC_Error_Handler::handleException(Throwable): void as an alias for logError($e, &quot;&quot;). TC_Entry_Service line 105 has been calling handleException() against a method that did not exist &mdash; any code path driving an exception through that catch would fatal with Call to undefined method. The alias delegates to logError so existing callers route through the same action hook and log file as the canonical entry point; the slice-63 TC_Test_Entry_Error_Handler_Stub stays in place as a safety net but the production-side fatal is no longer reachable. Shim suite: 1823 tests / 4049 assertions -&gt; 1862 tests / 4106 assertions (+39 / +57). Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/services/class-tc-error-handler.php from 0% -&gt; ~98%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.6.0</strong> &mdash; <?php _e('Test coverage (slice 67 &mdash; pin TC_Validation_Service at ~100%; CLOSES batch 7). Fifth and final slice of batch 7. New tests/GTValidationServiceTest.php (39 tests / 49 assertions) pins includes/services/class-tc-validation-service.php (89 statements) from 0% -&gt; ~100% coverage. Strategy: pure-logic methods (validateFieldId, validatePaginationParams, validateSearchTerm, validateSortParams, validateAjaxRequest) covered directly with no fixtures. validateTableData() error branches use form_id=0 to short-circuit isValidFormId() so sibling Mockery alias declarations of GFAPI from other tests do not leak across the suite. The GFAPI-missing branch and the two GFAPI-present branches (happy path and WP_Error) each run in isolated subprocesses (same isolation pattern as GTFormRendererTest). Inline shims for sanitize_text_field, sanitize_email, esc_url_raw, wp_verify_nonce, wp_roles so the real TC_Sanitization_Service (called on the happy path) resolves cleanly. Batch 7 close-out (slices 63 to 67, v5.5.6 -&gt; v5.6.0): TC_Entry_Service (slice 63, 358 statements), TC_Template_Service (slice 64, 201 statements), TC_Table_Builder (slice 65, 162 statements), TC_Divi (slice 66, 131 statements), and TC_Validation_Service (slice 67, 89 statements) all pinned at ~100%. Leading-digit bump-at-9 increment to v5.6.0 (next would have been 5.5.10 which is illegal under the rule). Shim suite: 1784/4000 -&gt; 1823/4049. Stays 0E/0F. Legacy 523/523 + 2 allowed-fail. Vitest 957/957. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.5.9</strong> &mdash; <?php _e('Test coverage (slice 66 &mdash; pin TC_Divi at ~100%). Fourth slice of batch 7. New tests/GTDiviTest.php (32 tests / 91 assertions) pins includes/class-tc-divi.php (131 statements) from 0% -&gt; ~100% coverage. Shim suite: 1752/3909 -&gt; ~1784/4000. Stays 0E/0F. Legacy 523/523 + 2 allowed-fail. Vitest 957/957. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.5.8</strong> &mdash; <?php _e('Test coverage (slice 65 &mdash; pin TC_Table_Builder at ~100%). Third slice of batch 7. New tests/GTTableBuilderTest.php (49 tests / 112 assertions) pins includes/class-tc-table-builder.php (162 statements) from 0% -&gt; ~100% coverage. Shim suite: 1703/3797 -&gt; ~1752/3909. Stays 0E/0F. Legacy 523/523 + 2 allowed-fail. Vitest 957/957. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.5.7</strong> &mdash; <?php _e('Test coverage (slice 64 &mdash; pin TC_Template_Service at ~100%). Second slice of batch 7. includes/services/class-tc-template-service.php ships the pre-built starter-template library (8 built-ins &mdash; pricing, feature-matrix, team-directory, event-schedule, league-standings, budget-tracker, faq, employee-list &mdash; plus a user-defined save-as-template hook persisted to the gt_user_templates option). Was 0% covered at v5.5.6 (201 statements). New tests/GTTemplateServiceTest.php (30 tests / 453 assertions) pins the full public surface. Public methods: get_templates() (built-in count, slug order, shape contract: slug / name / columns / sample_data / styling / settings, sample-row keys matching declared column ids, plus the user-defined append branch); get_template(slug) (known slug, null on miss, empty-slug miss, user-defined lookup, defensive null-coalesce when a stored entry omits the slug key); apply_template(slug) (full config build for pricing, unknown slug returns empty array, styling / settings default to empty array when source template omits them, from_template provenance set); get_user_templates() (empty default, is_array() guard against legacy non-array option payloads, round-trip read of stored array); save_as_template(table_id, name) (settings JSON parse + round-trip, sanitize_title slug generation user-{id}-{slug}, sanitize_text_field name trim + tag-strip, in-place replace when slug already exists, append when slugs do not collide, null-row tolerance, invalid-JSON tolerance, &lt;script&gt; tag stripping from name). Per-template spot checks: pricing (striped + highlight_header styling); feature-matrix (bordered preset, numeric column typing); team-directory (enable_search setting, email column type); event-schedule (default_sort_column / default_sort_direction, date column type); league-standings (compact preset, sort_tiebreakers list, 10 columns / 5 rows); budget-tracker (negative-variance sample row); faq (exactly 2 columns, enable_search); employee-list (enable_search + enable_pagination, date column type). Plus a constant guard pinning USER_TEMPLATES_OPTION = gt_user_templates so upgrade-path data continuity does not silently break. Pattern notes: WP_Mock::userFunction routes get_option / update_option through GLOBALS[_gt_test_options] for the duration of each test method &mdash; other test files (GTCloudStorageTest, GTGoogleSheetsTest, GTAutoImportTest, GTAiSettingsEncryptionTest, GTShortcodeRegisterScriptsTest) register Patchwork interceptions for these functions that can leak across suite boundaries, the WP_Mock layer wins the race per-test and tearDown cleanly releases; in-file shims for sanitize_title and sanitize_text_field (bootstrap supplies __() via WP_Mock but not the sanitize_* family); slice 21 SQL-pattern fixture seam under GLOBALS[gt_test_wpdb_fixtures][get_row] is reused for the settings-row read inside save_as_template. Shim suite: 1673 tests / 3344 assertions -&gt; 1703 tests / 3797 assertions (+30 / +453). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/services/class-tc-template-service.php from 0% -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.5.6</strong> &mdash; <?php _e('Test coverage (slice 63 &mdash; pin TC_Entry_Service at ~100%). First slice of batch 7. includes/services/class-tc-entry-service.php was the largest 0% file remaining at v5.5.5 (358 statements). New tests/GTEntryServiceTest.php (70 tests / 113 assertions) pins the full surface. Public methods: getEntries(int form_id, array params): array (invalid form_id catch-path via reflection-injected error-handler stand-in, since production TC_Error_Handler::handleException() does not exist; default-merge; page / per_page clamping; sort_order sanitization; happy-path wpdb fixture seam); updateEntry(int entry_id, array updates, int user_id): array (invalid id, permission denied, empty updates, happy path, TC_Database_Exception on wpdb-&gt;update failure); bulkAction(string action, array entry_ids, int user_id, array additional_data): array (delete / edit / export action paths plus every validation / permission / unknown-action error branch; export builds CSV payload via Mockery alias:GFAPI); createEntry(int form_id, array field_data, int user_id): int (GFAPI-missing via subprocess isolation, form-not-found, WP_Error form, add_entry WP_Error / false / success including array-value implode and skipped-field handling). Private helpers: canUserEditEntry (admin / editor / driver-owner / driver-deny / no-cap); canUserBulkEditEntries (short-circuit on first denial; all-allowed pass); addSearchFilters (no-filter no-op; search + 3-param bind; user_filter; date_from / date_to); addCustomFilters (text / dropdown / lookup; skip-non-array / skip-no-type / skip-empty-value); addSorting (entry_id; date_created; fallback when sort_field not in columns; date-aware STR_TO_DATE branch with both far-past 1900-01-01 and far-future 2099-12-31 sentinels); processEntryResults (date midnight vs non-midnight; dotted-id underscore alias; missing-field default; lookup processor); formatDateDisplay (empty; unparseable; midnight uses date format; non-midnight uses datetime format); getEntriesCount (bootstrap-stub zero; Mockery wpdb returning string-42 coerced to int). Test infrastructure introduced: TC_Test_Entry_Smart_WPDB_Stub extends TC_Bootstrap_WPDB_Stub to accept prepare(sql, array args) (the variant executeEntriesQuery and getEntriesCount use), TC_Test_Entry_Error_Handler_Stub substitutes for TC_Error_Handler so the catch branch in getEntries exercises without fatal-erroring on the non-existent handleException() method, reference passing for the ref-param helpers (addSearchFilters / addCustomFilters / addSorting) routes through Closure::bind (ReflectionMethod::invokeArgs cannot preserve refs through a variadic). Patterns reused: slice 34 (GTCollapsibleServiceTest &mdash; Reflection-driven pure-service coverage), slice 51 (GTChartTest &mdash; Mockery::mock alias:GFAPI), slice 58 (GTShortcodeApplyPasswordGateTest &mdash; in-file shims for WP functions not in bootstrap), slice 60 (GTShortcodeBuildColumnConfigTest &mdash; wpdb stub reset in setUp + reflection-injected error-handler stand-in). Shim suite: 1603 tests / 3231 assertions -&gt; 1673 tests / 3344 assertions (+70 / +113). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/services/class-tc-entry-service.php from 0% -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.5.5</strong> &mdash; <?php _e('Test coverage (slice 62 &mdash; pin TC_Shortcode plan-limit + permission helpers at ~100%; CLOSES shortcode decomp). Sub-slice F &mdash; the FINAL sub-slice of the includes/class-tc-shortcode.php mega-file decomposition (sub-slices A through F = slices 56 / 57 / 58 / 60 / 61 / 62). Pins the two remaining helpers at lines 1853-1937: checkTableAccessPermission(int form_id, array atts): bool (three-line delegate to TC_Table_Configuration::canCurrentUserViewTable()) and enforce_plan_limits(atts): string|null (the ~30-statement free-plan gate covering premium short-circuit, shortcode-attribute premium-feature scan for the three premium flags &mdash; show_advanced_filters, show_bulk_actions, enable_frontend_editing &mdash; and stored-settings scan for column-count vs TC_FREE_MAX_COLUMNS plus the three stored premium flags). New tests/GTShortcodeEnforceLimitsTest.php (29 tests / 46 assertions) pins: enforce_plan_limits premium short-circuit (premium user returns null even when every premium attribute is set); shortcode_parse_atts string-atts branch (string form reaches the premium-feature scan); non-array atts default to empty array; each of three premium features detected via shortcode attribute and as stored setting; falsy values (false, 0, empty string) do NOT trigger the upgrade notice; first-match ordering preserved across both shortcode-attr and stored-settings foreach (show_advanced_filters fires before show_bulk_actions); id=0 / id-missing skips the stored-check branch entirely; table-row-missing returns implicit null; malformed settings JSON (json_decode returns null) returns implicit null; column-count over TC_FREE_MAX_COLUMNS triggers the Table Limit Exceeded notice with both the actual count and the limit echoed; column-count at the boundary passes (the &gt; gate is strict); missing columns key in stored settings counts as zero (default array); column-check runs BEFORE premium-feature check (column overage + premium flag &mdash; column message wins); shortcode-attr scan runs BEFORE stored-settings scan (shortcode premium attr + stored column overage &mdash; shortcode message wins). checkTableAccessPermission pins: admin bypass via current_user_can(manage_options) returns true even when atts restrict roles; empty allowed_user_roles returns true (open table); missing allowed_user_roles key in atts returns true (falls through getAllowedUserRoles user_role_filter null branch); role mismatch returns false (current user roles do not intersect allowed); intersection returns true; user_role_filter single-string fallback wraps to array and matches; anonymous-user object with no roles property returns false on restricted table. Strategy: both methods are private; invoked through ReflectionMethod (slice 60 idiom). gt_is_premium() toggled via the slice 15 GLOBALS[gt_test_is_premium] license-helper seam. TC_Admin::get_table() swapped through the slice 28 GLOBALS[gt_test_admin_override] PHPUnit-shim seam so the stored-settings branch reads a fixture row instead of the DB. In-file shortcode_parse_atts shim (WP-core re-impl) drives the string-atts branch. checkTableAccessPermission tests run through the real (un-mocked) TC_Table_Configuration model. Closes the TC_Shortcode mega-file decomposition &mdash; sub-slices A (slice 56, asset enqueueing), B (slice 57, external-source dispatch helpers), C (slice 58, apply_password_gate), D (slice 60, column-config helpers), E (slice 61, render_table orchestrator), and F (slice 62, the remaining plan-limit + permission helpers) are all merged; includes/class-tc-shortcode.php is now fully covered by dedicated unit tests. Shim suite: 1574 tests / 3185 assertions -&gt; 1603 tests / 3231 assertions (+29 / +46). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/class-tc-shortcode.php lines 1853-1937 from partial -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.5.4</strong> &mdash; <?php _e('Test coverage (slice 61 &mdash; pin TC_Shortcode render_table orchestrator at ~100%). Fifth and final method-level sub-slice (E) of the includes/class-tc-shortcode.php decomposition. Pins the public render_table(array|string $atts): string orchestrator at lines 801-1140 &mdash; the biggest remaining unpinned method (~280 statements threading enforce_plan_limits, TC_Admin lookup, TC_Merged_Table delegation, three external-source dispatches, password gate, settings flatten, shortcode-overrides merge, and the GFAPI gate). New tests/GTShortcodeRenderTableTest.php (46 tests / 67 assertions; 41 active + 5 audit-trail skips) pins: enforce_plan_limits short-circuit (three premium-feature attrs blocked &mdash; show_advanced_filters / show_bulk_actions / enable_frontend_editing &mdash; with upgrade-notice HTML returned; premium user passes through to id-required error); preview mode (DOING_AJAX + $_POST[action]=gt_preview_table dispatch uses posted settings; default per_page falls back to get_option(gt_settings).default_per_page); id=0 / missing-id / empty-string-id error; table-not-found via TC_Admin override seam; unknown source type (current_user_can(manage_options) shows the diagnostic notice with the unrecognised type echoed; non-admin sees the generic gt-table-unavailable fallback; empty type string is NOT treated as unknown; customer-registered types via gt_registered_table_types filter pass); merged-table delegation (placeholder HTML with data-source-ids + data-total; empty source-ids JSON; malformed source-ids JSON; page clamped to min 1); settings JSON decode failure returns Invalid table configuration; JSON / Airtable / Notion data-source dispatch &mdash; exercised via the real TC_*_Source_Service helpers + reject-input pattern (table_id &lt;= 0 surfaces WP_Error gracefully; admin sees the diagnostic message; non-admin sees the temporarily-unavailable message); unknown data_source_type falls through to the standard render path; apply_password_gate locked when hash set + no cookie (gt-password-form HTML returned); lets through when hash empty or missing; settings flatten (nested settings.* unwrapped + merged with top-level keys; flat top-level passed through unchanged) observed via the gravity_tables_table_config filter capture seam; allowed_user_roles CSV parsing (editor, driver, admin trimmed to array); user_role_filter backward compat fallback; unlock_keys shortcode-override merge &mdash; 16 keys (#655 / #656 / #657 / #659): row_link_template, row_link_open_new_tab, print_all_rows, enable_vertical_scroll, vertical_scroll_max_height, horizontal_scroll, expiry_field_id, expiry_behavior, expiry_grace_days, expiry_inverse, table_width, css_class, show_export, show_print, show_pagination_info, table_style &mdash; with empty-string skip (DB value wins when shortcode passes empty) and explicit-value override (shortcode wins over DB); form_id + table_id captured into merged atts; enable_frontend_editing defaults to false for anonymous users with no edit caps; checkTableAccessPermission denial (gt-access-denied HTML when role-mismatch); admin bypass (manage_options always passes); open table (empty allowed_user_roles + non-admin); gt_registered_table_types filter receives default [gravity, merged] and is observed; gravity_tables_table_config filter fires after merge. Strategy: Mockery::mock(alias:GFAPI) unconditional in setUp() so class_exists(GFAPI) is deterministically true regardless of test order &mdash; works around alias-mock class declarations leaking across Mockery::close() boundaries (slice 60 and slice 51 leave the GFAPI declaration behind for the rest of the PHP process); TC_Admin::get_instance routed through $GLOBALS[gt_test_admin_override] seam from slice 28 with an anonymous-class fake exposing get_table(int): ?object; gt_is_premium() routed through $GLOBALS[gt_test_is_premium] (slice 15 / helpers-license); $wpdb reset to TC_Bootstrap_WPDB_Stub in setUp (other tests leave a Mockery mock behind); GTRenderTableCaptureHalt sentinel exception thrown from the gravity_tables_table_config filter callback to short-circuit render_table AFTER the merged $atts capture, BEFORE the templates/table.php include (2100+ lines &mdash; out-of-scope; covered by integration shim tests); 5 skipped tests carry audit-trail comments pointing at tests/test-issue-651-bulk-actions-defaulting.php (bulk_actions defaulting observable only after the GFAPI gate) and tests/test-issue-342-data-source-resolution.php (the GFAPI-missing branch hidden by setUp alias declaration). Shim suite: 1528 tests / 3118 assertions -&gt; 1574 tests / 3185 assertions (+46 / +67). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/class-tc-shortcode.php lines 801-1140 from partial -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.5.3</strong> &mdash; <?php _e('Test coverage (slice 60 &mdash; pin TC_Shortcode column-config helpers at ~100%). Sub-slice D of the includes/class-tc-shortcode.php decomposition. Pins the column-config helper cluster at lines 1491-1844: get_form_fields(int): array, get_system_field_config(string, array, array, array, array): ?array, and the ~120-statement build_column_config(array, array): array. New tests/GTShortcodeBuildColumnConfigTest.php (58 tests / 108 assertions) pins: get_form_fields (GFAPI-missing branch via @runInSeparateProcess; get_form returns false; get_form returns WP_Error; happy path via Mockery::mock(alias:GFAPI)); get_system_field_config (all 9 system fields entry_id / date_created / date_updated / created_by / ip / source_url / user_id / is_starred / is_read with their default shapes plus custom label override, filterable_fields include flips off, filterable_fields explicit include flips on, filter_configurations forces filterable=true, conditional_formatting attached, empty filter_config does not force, non-system returns null, empty id returns null); build_column_config (selected_fields vs columns vs default-all-fields source resolution; html/section/page skip in default-columns mode; empty id skip; non-array atts coercion; field_labels &gt; column_labels &gt; field-&gt;label fallback; Field-X placeholder for missing field; auto-generated non-editable system fields; field_configurations.editable wins over type-default; non-editable types html/section/page/captcha/signature; choices extraction from array shape with text/value fallbacks; choices extraction from object shape; driver_selector auto-lookup; ajs_lookup with explicit type; ajs_lookup default clients when ajs_lookup_type missing; lookup_fields user setting enables lookup; field_configurations.filterable explicit false and string-0 coerced; filterable_fields exclude; filterable default true; sortable default true; sortable_fields exclude; render_shortcodes off by default + explicit true; conditional_formatting per field; filter_configurations per field; system-field short-circuit; composite mixed-shape; empty form + empty atts returns empty array). Strategy: in-file function_exists shims for add_shortcode + __ + esc_html__ (bootstrap does not pre-declare these for the shortcode-constructor flow); Mockery::mock(alias:GFAPI) for the get_form gate (same pattern as GTChartTest); singleton reset via Reflection on TC_Shortcode::$instance between tests; wpdb stub reset to TC_Bootstrap_WPDB_Stub when other tests leave a Mockery mock behind &mdash; build_column_config dereferences $wpdb-&gt;prefix on the ajs_lookup branch; @runInSeparateProcess for the GFAPI-missing branch so the alias declaration does not leak into other tests. Shim suite: 1470 tests / 3005 assertions -&gt; 1528 tests / 3118 assertions (+58 / +113). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/class-tc-shortcode.php lines 1491-1844 from partial -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.5.2</strong> &mdash; <?php _e('Fixed (#1178): JSON-source tables render with empty headers and cells. TC_Shortcode::render_json_source_table built column_keys by reading col[name], but TC_JSON_Source_Service::infer_columns() returns the column id under col[id]. The name key never existed, so JSON-source tables rendered an empty &lt;thead&gt; and empty &lt;td&gt; cells. Fix prefers col[id], falls back to col[name] for forward compat. Regression test in tests/GTShortcodeExternalSourceTest.php flipped from pinning the bug to asserting the correct headers and cell values now render. Shim 0E/0F. Legacy 523/523 + 2 allowed-fail. Vitest 957/957.', 'tc-data-tables'); ?></li>
                    <li><strong>5.5.1</strong> &mdash; <?php _e('Test coverage (slice 58 &mdash; pin TC_Shortcode password gate + form at ~100%). Sub-slice C of the includes/class-tc-shortcode.php decomposition. Covers the table-locked password gate: form rendering, password check, redirect path, legacy-shape compatibility. New tests/GTShortcodeApplyPasswordGateTest.php (25 tests / 45 assertions / 1 skipped per the slice-49 strategy-fallback unreachable-shim pattern). Shim suite: 1445 tests / 2960 assertions -&gt; 1470 tests / 3005 assertions (+25 / +45). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: TC_Shortcode password-gate methods 0% -&gt; ~100%. No customer-facing runtime change. Recovers the stalled batch-5 agent slice 58.', 'tc-data-tables'); ?></li>
                    <li><strong>5.5.0</strong> &mdash; <?php _e('Test coverage (slice 57 &mdash; pin TC_Shortcode external-source render path at ~100%). Second sub-slice of the includes/class-tc-shortcode.php decomposition. Pins the 6 methods that compose the external-source (JSON / Airtable / Notion) render path (lines 1163-1367): render_json_source_table, render_external_source_short_circuit (shared helper), render_external_source_table_html (shared helper), render_airtable_source_table, union_of_row_keys (pure helper), render_notion_source_table. New tests/GTShortcodeExternalSourceTest.php (28 tests / 48 assertions) pins: union_of_row_keys (empty input; first-seen order; dedup; sparse rows; scalar-row cast handling); render_external_source_short_circuit (null pass-through for non-empty rows; gt-kind-source-empty for empty rows; admin sees gt-kind-source-error + error message + error code for WP_Error; public sees generic temporarily unavailable message with sensitive details hidden; class attribute uses source kind); render_external_source_table_html (wrapper class + id from table_id; h3 title only when non-empty; thead columns in array order; cell values; empty-string fallback for missing keys; wp_json_encode + esc_html for array/object cell values; cell text esc_html-escaped; meta footer row count + class); render_json_source_table (WP_Error html branch for invalid table_id; empty-state branch via pre-warmed transient cache; cached-rows happy path with wrapper + title + footer; pinned current empty-thead behaviour pending bug #1178 fix); render_airtable_source_table (WP_Error html branch; cached-rows happy path); render_notion_source_table (WP_Error html branch; cached-rows dispatch shape). Strategy: bootstrap pre-loads TC_JSON_Source_Service / TC_Airtable_Sync_Engine / TC_Notion_Sync_Engine, so Mockery::mock(alias:...) would conflict. Instead, exercise the real helpers via table_id &lt;= 0 -&gt; WP_Error gt_invalid_table_id (slice 50 real-helper-plus-reject-input pattern) and $wpdb fixture seam ($GLOBALS[gt_test_wpdb_fixtures][get_row]) + get_transient shim to drive cache-hit path. Pure private helpers tested via ReflectionMethod. Cross-test contamination patch: reset $wpdb to the bootstrap TC_Bootstrap_WPDB_Stub in setUp because earlier test files replace $wpdb with Mockery mocks and do not restore it. Cross-suite Patchwork lesson: WP_Mock autoload-time _n() stub returns predefined_return_function_helper which does not pick singular vs plural by the number arg; override it via WP_Mock::userFunction(_n) in setUp. Production bug surfaced &mdash; filed as #1178: class-tc-shortcode.php:1185 reads $col[name] but TC_JSON_Source_Service::infer_columns() returns $col[id], so JSON-source tables render an empty thead + empty tbody data cells. Out of scope for this coverage slice. Shim suite: 1417 tests / 2912 assertions -&gt; 1445 tests / 2960 assertions (+28 / +48). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/class-tc-shortcode.php lines 1163-1367 from 0% / partial -&gt; ~100%. Bump-at-9 leading-digit increment &mdash; patch 5.4.9 (trailing-9) rolls up to minor 5.5.0. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.4.9</strong> &mdash; <?php _e('Test coverage (slice 56 &mdash; pin TC_Shortcode register_scripts at ~100%). First sub-slice of the includes/class-tc-shortcode.php decomposition (1937 lines, ~1111 statements). Pins the smaller public surface of TC_Shortcode plus the large imperative register_scripts() method (lines 25-799). Methods covered: get_instance (singleton), __construct (wiring of add_shortcode + add_action + add_filters), no_texturize_shortcodes, add_version_attribute, register_scripts (~330 statements, 62 wp_register_script + wp_register_style calls + thickbox + css_framework 3-branch switch). New tests/GTShortcodeRegisterScriptsTest.php (29 tests / 225 assertions) pins: singleton + constructor wiring (add_shortcode gravity_table + gravity_tables; add_action wp_enqueue_scripts; add_filter script_loader_tag + no_texturize_shortcodes); no_texturize_shortcodes appends both tags + preserves input + works on empty input; add_version_attribute (handle mismatch pass-through; injects data-gt-version for gravity-tables-frontend handle; esc_attr wrap verified); register_scripts high-level invariants (add_thickbox called once; &gt;50 handles registered; every handle uses TC_VERSION; every handle footer-loaded; every src starts with TC_PLUGIN_URL); register_scripts dependency-chain spot-checks (frontend-core has no deps; frontend-util has jquery; frontend-entry-row has the 4 documented sub-module deps; main gravity-tables-frontend handle has jquery + thickbox + frontend-core + frontend-init + 30+ chain deps; frontend-bundle has jquery + thickbox); register_scripts css_framework 3-branch switch (default -&gt; frontend.css; minimal -&gt; frontend-minimal.css; none -&gt; handle registered with false src + thickbox dep; missing gt_settings option falls back to default; gt_settings without css_framework key falls back to default); register_scripts print + data-source-render styles (print media + frontend dep; data-source-render style enqueued); every style uses TC_VERSION; shortcode callbacks bound to [instance, render_table]. Strategy: in-file function_exists shims for add_shortcode + add_thickbox + wp_register_script + wp_register_style + wp_enqueue_style (bootstrap does not pre-declare any); WP_Mock::userFunction (Patchwork) for get_option because tests/GTAdminLoggerSeamTest.php declares its own get_option() with if (!function_exists) first (alphabetical order), shadowing any in-file shim added later &mdash; same lesson as slice 55 get_current_user_id collision. Shim suite: 1388 tests / 2687 assertions -&gt; 1417 tests / 2912 assertions (+29 / +225). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/class-tc-shortcode.php lines 25-799 partial -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.4.8</strong> &mdash; <?php _e('Bug fix (#1168 &mdash; REST create_entry numeric-string field-key reindex). Customer-facing bug fix. REST clients posting fields={&quot;5&quot;:&quot;foo&quot;,&quot;7&quot;:&quot;bar&quot;} to POST /wp-json/gravity-tables/v1/tables/&lt;id&gt;/entries had their values silently land at the wrong Gravity Forms field ids (0 and 1 instead of 5 and 7). includes/class-tc-rest-api.php::create_entry built the entry payload via array_merge($defaults, normalize_fields($fields)); PHP array_merge re-canonicalises numeric-string keys to integers AND reindexes them, so the GF field-id keys returned by normalize_fields were destroyed in transit. Any REST caller using string field keys (rather than input_5_1 shape) was effectively writing to the wrong fields. Discovered by slice 52 / PR #1167 while filling REST API coverage. Fix: replace array_merge with the + union operator (left-hand wins) &mdash; normalize_fields() GF-id keys take priority; defaults (form_id, status, created_by) fill in the rest; numeric keys preserved verbatim. Same audit confirmed on update_entry (line 330) &mdash; already uses foreach, no fix needed. Adds tests/GTRestApiTest.php::test_create_entry_preserves_numeric_string_field_keys_unchanged_1168 regression test. Shim suite: 1387 tests / 2682 assertions -&gt; 1388 tests / 2687 assertions (+1 / +5). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Closes #1168.', 'tc-data-tables'); ?></li>
                    <li><strong>5.4.7</strong> &mdash; <?php _e('Test coverage (slice 54 &mdash; pin TC_Auto_Import at ~100%). Fifth and final file of the post-slice-49 batch (slices 50-54 complete). includes/class-tc-auto-import.php (145 statements). Slice 29 (#1075) already pinned the SSRF-gate branch via gt_validate_outbound_url; this slice fills every other branch and lifts coverage to ~100%. New tests/GTAutoImportTest.php (42 tests / 82 assertions) pins: singleton + constructor wiring (add_filter cron_schedules + add_action on gt_auto_import_run / wp_ajax_gt_manual_import / init); register_cron_schedules (appends gt_every_6h with 21600s interval; preserves existing entries); schedule / unschedule happy + no-op paths; maybe_reschedule_all (no-op on empty list, skips tables without auto_import_url or auto_import_schedule, skips already-scheduled crons, schedules missing crons with sanitize_key-d recurrence, falls back to daily when sanitised empty); fetch_and_import early exits (missing table, missing auto_refresh_url); SSRF-gate reject branch via the real gt_validate_outbound_url helper (http://localhost/ rejected unconditionally); fetch_and_import response branches (WP_Error from wp_remote_get; 304 Not Modified -&gt; success path resets failure_count; 502/503/504 transient failures dont increment failure_count; 404 records failure with HTTP code; body exceeding MAX_SIZE 5MB records failure; 200 happy path caches ETag + Last-Modified headers); conditional-request headers (If-None-Match + If-Modified-Since threaded from stored state when present); failure-threshold admin email (3rd consecutive failure triggers wp_mail with sprintf-formatted subject; failures below threshold do not email; success resets failure_count to 0); AJAX manual import (invalid table_id error; lacks-perms error via $this-&gt;user_can toggle pattern to work around Patchwork ignoring re-registered stubs; happy path returns last_refresh + status via $GLOBALS[gt_test_response] throw seam); public query API #408 (run_import returns true on success + false on failure; get_last_updated null when never-run, last_import preferred over last_refresh; get_last_error null + non-empty + empty-string-as-null; get_status defaults for never-run table; threads next_scheduled; normalises non-array state corruption to defaults). Strategy: WP_Mock::userFunction closures bound to $this properties including the $this-&gt;user_can toggle trick; real gt_validate_outbound_url from bootstrap handles SSRF reject via http://localhost/; Mockery-mocked $wpdb for active-row + reschedule queries; slice-20 throw seam $GLOBALS[gt_test_send_json_throws] for the AJAX handler; reflection reset on TC_Auto_Import::$instance between tests. Shim suite: 1345 tests / 2600 assertions -&gt; 1387 tests / 2682 assertions (+42 / +82). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/class-tc-auto-import.php partial -&gt; ~100%. Batch 50-54 totals: +234 tests / +464 assertions; 5 files at ~100%; 5 versions; 1 production-bug follow-up filed (#1168). No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.4.6</strong> &mdash; <?php _e('Test coverage (slice 53 &mdash; pin TC_Import at ~100%). Fourth file of the post-slice-49 batch. includes/class-tc-import.php (238 statements, 0% baseline). The CSV Import admin submenu (#37 MVP). New tests/GTImportTest.php (35 tests / 65 assertions) pins: transform_csv_cell static API (HTML sanitisation, non-scalar safety, toggle truthy/falsy normalisation, toggle precedence over formula detection, formula wrap for storage, plain scalar pass-through); singleton + admin_menu + admin_post_gt_import_csv constructor wiring; add_admin_menu (submenu registered under gravity-tables parent with manage_options cap); render_page (insufficient-perms wp_die; no-tables warning; form rendered when tables present; success notice; warning notice with Row N: message; formula-warnings details block; transient consumed after render); handle_post WP_Error branches via throwing wp_safe_redirect shim (insufficient perms, missing table_id, missing file, file too large &gt;5MB, table not found, GFAPI missing via @runInSeparateProcess, form missing both false and WP_Error, CSV empty); handle_post happy path (imports rows, persists result transient); UTF-8 BOM stripped from first header; unmapped headers collected; blank rows skipped; per-row errors collected; errors truncated at 25 with Further errors truncated sentinel; toggle columns normalised via table_settings.column_config[type=toggle]; formula warnings collected for unsupported functions like XLOOKUP via TC_CSV_Formula_Detector; hidden (trashed) entries preserved before import + restored after via GFAPI::update_entry(status=trash). Strategy: in-file function_exists shims for wp_die + wp_safe_redirect that THROW so the handler exit() never fires in the test process &mdash; captures redirect URL via $GLOBALS[_gt_test_redirect_url]; Mockery::mock(wpdb) with prefix + pass-through prepare; Mockery::mock(alias:GFAPI) for entry CRUD; WP_Mock::userFunction for transient (set/get/delete); @runInSeparateProcess for the GFAPI-missing branch in handle_post; reflection reset on TC_Import::$instance between tests. Shim suite: 1310 tests / 2535 assertions -&gt; 1345 tests / 2600 assertions (+35 / +65). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/class-tc-import.php 0% -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.4.5</strong> &mdash; <?php _e('Test coverage (slice 52 &mdash; pin TC_REST_API at ~100%). Third file of the post-slice-49 batch. includes/class-tc-rest-api.php (218 statements). Slice 30 (#1076) already pinned the safe-settings allowlist branch via tests/GTRestSettingsAllowlistTest.php; this slice fills every other branch and lifts coverage to ~100%. New tests/GTRestApiTest.php (52 tests / 128 assertions) pins: singleton + add_action(&quot;rest_api_init&quot;) constructor wiring; register_routes (4 route groups registered with correct paths; every method has a permission_callback set to one of permission_read / permission_read_table / permission_write_table; no __return_true / is_user_logged_in bare-callback usage &mdash; audit invariant from #545); permission_read (WP_Error when logged out, true when logged in); permission_read_table (read failure propagates, missing table -&gt; not_found, role allowlist mismatch -&gt; rest_forbidden, role match -&gt; true, administrator bypasses role check, empty allowed_user_roles makes table public); permission_write_table (read_table failure propagates, missing write cap -&gt; rest_forbidden, each of edit_posts / publish_posts / gravityforms_edit_entries / administrator independently grants write); list_tables (empty result, role-filtering drops tables user cannot view, dropped rows where load_table_or_error returns WP_Error); get_table (not_found, role denial, response shape with sensitive-key stripping via gt_rest_filter_safe_settings); list_entries (table missing; GFAPI missing via @runInSeparateProcess; paged response with X-WP-Total + X-WP-TotalPages headers; lower-bound clamps page&gt;=1 + per_page&gt;=1; upper-bound clamp per_page&lt;=200; unknown order falls back to DESC; search threads as field_filters; filter object threads dropping empty/null values; get_entries WP_Error propagated as gf_error); create_entry (table missing, empty fields, 201 happy path, add_entry WP_Error propagated as create_failed, non-scalar fields normalize via wp_json_encode); update_entry (table missing, existing missing both false and WP_Error, form_id mismatch, empty fields, happy path returns fresh entry, update_entry_field WP_Error propagated as update_failed); delete_entry (table missing, existing missing, form_id mismatch, delete_entry WP_Error propagated as delete_failed, happy path returns deleted=true + entry_id); user_can_view_table edge case (logged-in user with ID=0 + role-allowlist configured returns false). Strategy: in-file function_exists shims for register_rest_route + is_user_logged_in + current_user_can + wp_get_current_user + get_current_user_id matching tests/test-issue-560-slice2-rest-endpoint.php; stub classes for WP_REST_Request (ArrayAccess + get_param), WP_REST_Response (data + headers), WP_REST_Server constants; Mockery::mock(alias:GFAPI) for entry CRUD; Mockery::mock(wpdb) with prefix + pass-through prepare; @runInSeparateProcess for the GFAPI-missing branch in list_entries; reflection reset on TC_REST_API::$instance between tests. Production note (issue #1168 filed): class-tc-rest-api.php:297 uses array_merge with the default entry shape and normalize_fields output; PHP re-canonicalises numeric-string keys to ints AND reindexes them, so a REST client posting fields={&quot;5&quot;:&quot;foo&quot;} lands the value at key 0. Not in scope for this coverage slice. Shim suite: 1258 tests / 2407 assertions -&gt; 1310 tests / 2535 assertions (+52 / +128). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/class-tc-rest-api.php partial -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.4.4</strong> &mdash; <?php _e('Test coverage (slice 51 &mdash; pin TC_Chart at ~100%). Second file of the post-slice-49 batch. includes/class-tc-chart.php (216 statements, 0% baseline). The &lbrack;gravity_chart&rbrack; shortcode (#34 MVP) &mdash; bucketed aggregation + inline SVG bar / donut renderers. New tests/GTChartTest.php (37 tests / 65 assertions) pins: singleton + add_shortcode(&quot;gravity_chart&quot;, ...) constructor wiring; render_shortcode input validation (missing/zero table_id, non-array atts, table row not found, GFAPI missing, blank group_by + field, empty buckets -&gt; gt-chart-empty); bar chart happy path (count aggregation, sort-by-value desc, title + default aria-label, width / height floor 280px / 180px, label truncation 12 chars + U+2026 ellipsis, limit clamp to 1 (lower) and 50 (upper), unknown type falls back to bar, COUNT footer); donut variant (legend rendering, center total, long-label truncation 16 chars + U+2026, title + default aria-label); aggregation matrix (SUM=60, AVG=20, MIN=7, MAX=99, unknown agg falls back to count); aggregation edge cases (non-numeric val skipped in SUM, empty-bucket-label row dropped, unsafe chars stripped from field id via preg_replace allow-list, all-unsafe field id -&gt; empty-state, group_by falls back to field when unset, explicit group_by != field threads through separate meta join, AVG with 0 valid samples returns 0.0, MIN / MAX with all-null returns dropped via array_filter -&gt; empty-state); format_number (integer branch count=7 and decimal branch avg=1.50). Strategy: in-file function_exists shims for add_shortcode + shortcode_atts + esc_html__ matching tests/GTMapTest.php shape; Mockery::mock(&quot;wpdb&quot;) with prefix + pass-through prepare; Mockery::mock(&quot;alias:GFAPI&quot;) for the class_exists gate; @runInSeparateProcess for the &quot;GFAPI missing&quot; branch so the alias declaration from earlier tests does not leak; reflection reset on TC_Chart::$instance between tests. Shim suite: 1221 tests / 2342 assertions -&gt; 1258 tests / 2407 assertions (+37 / +65). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/class-tc-chart.php 0% -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.4.3</strong> &mdash; <?php _e('Test coverage (slice 50 &mdash; pin TC_Google_Sheets at ~100%). First file of the post-slice-49 batch (slices 50-54). includes/class-tc-google-sheets.php (179 statements). Slice 29 (#1075) already covered the SSRF-gate branch via gt_validate_outbound_url; this slice fills everything else and lifts coverage to ~100%. New tests/GTGoogleSheetsTest.php (68 tests / 124 assertions) pins: is_sheets_url (https + http variants, case-insensitive; rejects non-sheets URLs); to_csv_export_url (pub-CSV pass-through, no-ID input pass-through, plain /edit URL gets ?format=csv, query and fragment gid both carry through to &amp;gid=N); fetch_public_csv (200 happy path with timeout + branded user-agent header, SSRF reject via http://localhost/ (rejected unconditionally by the real gt_validate_outbound_url), WP_Error from wp_remote_get, non-200 response); fetch_private_sheet (missing key, missing ID, 200 happy path with headers + rows, sheet-name rawurlencode, WP_Error from wp_remote_get, non-200 with error.message in body, non-200 fallback to HTTP %d, 200 with values: [], 200 without values key); parse_csv_to_rows (standard CSV, UTF-8 BOM strip, CRLF + CR normalisation, blank-line skip, header trim); map_column_types (date / url / image / number / text classification heuristics, col_N fallback for empty headers, &quot;Photo Url&quot; precedence pinned); get_cached_data (transient hit + miss); refresh_table (both modes public + private, TTL mapping for 15min / hourly / twicedaily / daily / unknown-fallback, stale-fallback with and without prior update_option data, default sheets_tab falls back to Sheet1); get_last_synced (null when never synced + human-readable string); schedule_sync / unschedule_sync (manual short-circuit, unknown interval falls back to hourly, 15min maps to gt_15min, already-scheduled skip, no-op when nothing scheduled, default interval hourly for unschedule_sync); handle_cron_sync (missing row, settings without url/id, dispatches refresh_table for both public + private flavours via a Mockery-mocked $wpdb); get_csv_url / fetch / get_cached standardised public API (#409) including the WP_Error cache-poison-prevention branch. Strategy: WP_Mock::userFunction for wp_remote_* + transient + options + sanitize_* + human_time_diff + cron helpers (Patchwork wins the function-symbol race); $GLOBALS[_gt_test_action_callbacks] for constructor wiring; reflection reset on singleton; the real gt_validate_outbound_url from includes/helpers-url.php is loaded by bootstrap, so the SSRF-reject test passes the production helper a http://localhost/ URL rather than fighting for the function-symbol declaration slot. Shim suite: 1153 tests / 2218 assertions -&gt; 1221 tests / 2342 assertions (+68 / +124). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/class-tc-google-sheets.php partial -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.4.2</strong> &mdash; <?php _e('Test coverage (slice 49 &mdash; pin TC_Form_Renderer at ~95%). Fourth (and final) per-file fill of the post-slice-45 phase. includes/renderers/class-tc-form-renderer.php (178 statements, 0% in v5.1.8 baseline). New tests/GTFormRendererTest.php (37 tests / 74 assertions; 3 skipped) pins: get_instance (singleton); render_form_html primary path (enqueues gform_gravityforms + gform_basic + gform_theme; threads form_id + ajax=true to gravity_form_enqueue_scripts; wraps output in &lt;div class=&quot;gt-form-container&quot;&gt;; emits auto-close script with gform_confirmation_loaded + gform_post_render + tb_remove); render_form_html short-output fallback (when gravity_form returns &lt;200 chars OR no &lt;form substring); render_manual_fallback (returns early when GFAPI missing; handles WP_Error from GFAPI::get_form; skips hidden fields; decodes table_config settings JSON for lookup_fields); render_manual_field for every supported type (text/email/phone type-attribute pass-through; textarea; date with gt-modal-date-wrapper + html5 + display inputs + placeholder; number with range min/max/step; number decimal_comma; select non-lookup; select user-lookup with 3 user_field branches: display_name + user_login + user_email + unknown-branch default fallback + empty-label user dropped + non-user lookup type treated as plain choices; radio + no-choices empty; checkbox with input[] notation + no-choices empty; default falls back to text input); render_driver_selector (array roleFilter -&gt; role__in; string roleFilter exploded on comma + trimmed; no roleFilter + probe finds drivers -&gt; role=driver; no roleFilter + probe empty -&gt; exclude=[1]; display_name preferred; falls back to first_name+last_name; falls back to user_login when all else blank; plus dispatch from render_manual_field). Strategy: in-file function_exists shims for gravity_form + gravity_form_enqueue_scripts + wp_enqueue_script (1-arg form matching tests/GTMapTest.php shape) + wp_enqueue_style + get_users; Mockery alias:GFAPI for manual_fallback get_form branches; Mockery alias:GFFormDisplay in @runInSeparateProcess tests; Mockery wpdb for table_config lookup; Reflection invocation of private methods. 3 skipped tests &mdash; the file-top function_exists shim declares gravity_form before bootstrap runs, so even isolated processes inherit the declaration; the GFFormDisplay fallback branch + the &quot;neither&quot; exception branch (3% of the file) skip rather than fail. Acceptable: that code is dead on any modern Gravity Forms install. Shim suite: 1116 tests / 2144 assertions -&gt; 1153 tests / 2218 assertions (+37 / +74). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/renderers/class-tc-form-renderer.php 0% -&gt; ~95%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.4.1</strong> &mdash; <?php _e('Test coverage (slice 48 &mdash; pin TC_WooCommerce at ~100%). Third per-file fill of the post-slice-45 phase. includes/class-tc-woocommerce.php (174 statements, 0% in v5.1.8 baseline). New tests/GTWooCommerceTest.php (31 tests / 74 assertions) pins: get_instance + constructor (singleton + add_action on wp_ajax_gt_create_wc_product / wp_ajax_nopriv_gt_create_wc_product / wp_footer + add_filter on gt_table_config); is_woocommerce_active (false when class missing); inject_wc_flags (active=false + mapping_ready=false + mapping stdClass when WooCommerce missing; mapping_ready=true when title set; ignores non-array wc_mapping); create_product WP_Error branches (wc_inactive, permission_denied, Invalid request, Gravity Forms not active, Entry not found, Table not found, no_mapping, empty_title, wc_save_failed); create_product happy path (threads all four mapped fields through WC_Product_Simple setters, saves, stamps _gt_source_entry_id + _gt_source_table_id post meta, returns product_id + product_title + edit_url + view_url); create_product SKU collision recovery (set_sku throws -&gt; catch resets SKU to empty and continues); enqueue_compat_script (no-op when WooCommerce inactive; calls wp_add_inline_script with wc-add-to-cart handle when active); get_product_table_entries (happy path builds row from WP_Query + wc_get_product; blanks fields without WooCommerce; empty posts returns []; clamps per_page + page to &gt;=1; uppercases order + falls back to DESC; threads search + tax_query; threads min-only + max-only meta_query). Strategy: standalone real WP_Query class (not Mockery overload &mdash; overload returned a fresh instance from new WP_Query losing the seed mock property values, root cause of 6 initial test failures); Mockery::mock(&quot;overload:WC_Product_Simple&quot;) required (not alias) because production does new WC_Product_Simple(); Mockery::mock(&quot;alias:WooCommerce&quot;) + alias:GFAPI same pattern as slice 40 + slice 44; @runInSeparateProcess + @preserveGlobalState disabled on tests that mock WooCommerce so the class declaration does not leak; in-file function_exists shims for sanitize_text_field / sanitize_key / wp_kses_post / wp_parse_args / absint / admin_url / get_permalink / get_the_post_thumbnail / update_post_meta / current_user_can / __ / wc_get_product / wp_add_inline_script; slice-20 throw seam + gt_test_response for wp_send_json_* observation; tearDown resets globals + $_POST. Shim suite: 1085 tests / 2070 assertions -&gt; 1116 tests / 2144 assertions (+31 / +74). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/class-tc-woocommerce.php 0% -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.4.0</strong> &mdash; <?php _e('Test coverage (slice 47 &mdash; pin TC_External_DB at ~95%). Second per-file fill of the post-slice-45 phase. includes/class-tc-external-db.php (164 statements, 0% in v5.1.8 baseline). New tests/GTExternalDBTest.php (38 tests / 69 assertions) pins: get_instance + constructor (singleton + add_action on wp_ajax_gt_test_db_connection); get_connections (no-cap empty + unset empty + stored array + non-array corruption normalised); add_connection (permission_denied without cap; happy path stores + encrypts password + returns index; appends at next index; defaults mysql driver + 3306 port; defaults sqlsrv 1433; rejects unknown driver falling back to mysql; defaults localhost; readonly=false threaded; empty password stays empty; round-trips through private decrypt_value); delete_connection (false without cap; false for missing index; removes + reindexes); test_connection (permission_denied without cap; driver_not_available when sqlsrv missing; connection_failed against 127.0.0.1:1 for mysql + mysql+ssl); execute_query (permission_denied without gravity_tables_view_external cap; invalid_connection when index missing; readonly_violation for non-SELECT under default readonly; readonly regex tolerates leading whitespace then fails as query_failed at PDO connect; non-readonly lets UPDATE through to PDO; sqlsrv driver_not_available; mysql happy decrypt + query_failed at PDO connect); ajax_test_connection (sends error when user lacks cap; sends error on PDO failure; sends success when test_connection returns true via anonymous-class singleton swap); is_sqlsrv_available (matches PDO::getAvailableDrivers()); private encrypt_value + decrypt_value (empty-string passthrough; unicode + punctuation round-trip; short-blob base64 fallback). Strategy: WP_Mock::userFunction for update_option/get_option/current_user_can (Patchwork-driven); bootstrap-level check_ajax_referer + wp_send_json_* cannot be overridden via WP_Mock (DefinedTooEarly) so use the slice-20 opt-in throw seam (gt_test_send_json_throws) + gt_test_response to observe; ReflectionProperty resets singleton per test; PDO not mocked &mdash; WP_Error branches tested directly + PDO failure paths exercise the connection_failed/query_failed wrap by pointing at 127.0.0.1:1 (no listener); tearDown explicitly resets gt_test_send_json_throws + gt_test_response + $_POST so downstream tests (notably GTPerformanceTest) inherit a clean slate. Shim suite: 1047 tests / 2001 assertions -&gt; 1085 tests / 2070 assertions (+38 / +69). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/class-tc-external-db.php 0% -&gt; ~95% (PDO happy-path inner blocks for SELECT execution remain uncovered). Bump-at-9 leading-digit increment &mdash; patch 5.3.9 (trailing-9) rolls up to minor 5.4.0. Production note: includes/class-tc-external-db.php line 154 uses PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT which is deprecated under PHP 8.5; not in scope for this coverage slice, flagged for a future maintenance pass. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.3.9</strong> &mdash; <?php _e('Test coverage (slice 46 &mdash; pin TC_Cloud_Storage at ~100%). First per-file fill of the post-slice-45 phase. includes/class-tc-cloud-storage.php (114 statements). Token store/get already covered by tests/GTCloudStorageTokenEncryptionTest.php (slice 30 / #1076); this fill closes everything else. New tests/GTCloudStorageTest.php (52 tests / 79 assertions) pins: get_instance / constructor (singleton identity + add_action on CRON_HOOK + add_filter on cron_schedules); is_supported_url (5 happy patterns: OneDrive Live, 1drv.ms, SharePoint, Google Drive, Google Docs Sheets + arbitrary-URL rejection); fetch_remote_file (200 happy path returns body + sets timeout + branded user-agent; WP_Error / 4xx / 5xx all return false; URL rewriting: Google Drive share -&gt; direct download, Google Sheets published-CSV passthrough, OneDrive Live ?download=1 append, SharePoint &amp;download=1 with existing query, 1drv.ms append, arbitrary URL passthrough); get_cached_data (transient hit + miss); refresh_now (missing-URL early return; happy path caches + populates stale option + returns body; TTL for 15min / hourly / twicedaily / daily / unknown-fallback; remote-failure stale fallback also writes the gt_cloud_fetch_error_&lt;id&gt; admin-notice flag; remote-failure with no stale copy returns false); schedule_refresh (manual + empty interval both unschedule; already-scheduled is no-op; not-scheduled schedules with hourly; 15min maps to gt_15min recurrence; unknown interval falls back to hourly); unschedule_refresh (no-op + remove-when-scheduled paths); handle_cron_refresh (missing row triggers unschedule; happy path decodes settings JSON; malformed JSON falls back to []); add_cron_schedules (appends gt_15min; preserves existing gt_15min; keeps unrelated); authenticate (stored OneDrive token; sharepoint + microsoft aliases normalised to onedrive; missing OneDrive token returns gt_oauth_required; legacy gt_google_access_token option threads through with gt_decrypt_secret; googledrive alias; missing Google token returns gt_oauth_required; unknown + missing provider both return gt_unknown_provider). Strategy: WP_Mock::userFunction for transient + wp_remote_* + cron + options (Patchwork wins over any bootstrap fallbacks); bootstrap _gt_test_action_callbacks / __filters registries observed directly for add_action/add_filter wiring; ReflectionProperty resets the singleton per test; Mockery-mocked $wpdb for handle_cron_refresh get_row. Shim suite: 995 tests / 1922 assertions -&gt; 1047 tests / 2001 assertions (+52 / +79). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/class-tc-cloud-storage.php partial -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.3.8</strong> &mdash; <?php _e('Bug fix (#1152, slice 45 &mdash; test-infra frontend-print.test.js timer cleanup). The two initPrint describe blocks in tests/js/frontend-print.test.js exercise code paths in assets/js/frontend/print.js that schedule setTimeout(50) calls to window.print(). Tests asserted side-effects synchronously and finished before the timer fired, leaving the timer pending. JSDOM teardown removed the spied window.print method, the stale timer fired, and crashed with TypeError: window.print is not a function. The unhandled exception flipped vitest exit code to non-zero even with 957/957 tests passing &mdash; observed on slice 39 (v5.3.2) and slice 44 (v5.3.7) first build-release.sh runs. Fix: vi.useFakeTimers() in beforeEach + vi.clearAllTimers() + vi.useRealTimers() in afterEach for both initPrint blocks. Fake timers queue the 50ms callback but never fire it, so the timer is cleanly discarded on teardown. All tests still assert the same side-effects synchronously (loadEntries called/not called, per_page mutation, etc.). Convention documented in vitest.config.js comment block: any future test exercising setTimeout/setInterval paths follows the same pattern. Verified via 10 consecutive npm test runs &mdash; 10/10 green. No customer-facing runtime change &mdash; internal test-infra only. Closes #1152.', 'tc-data-tables'); ?></li>
                    <li><strong>5.3.7</strong> — <?php _e('Test coverage (slice 44 — pin TC_Map at ~100%). Fourth (and final) per-file fill of the post-slice-40 phase. includes/class-tc-map.php (108 statements, 0% in the v5.1.8 baseline). New tests/GTMapTest.php (25 tests / 43 assertions) pins the full public surface: get_instance / constructor (singleton identity + add_shortcode(&quot;gravity_map&quot;, ...) wired at construction); render_shortcode (input validation: 3 required-field errors + table-not-found + non-array atts; wrapper HTML with random map id; height floor 180px minimum; asset enqueue gt-leaflet style + script; inline init JS with points payload mapId / lat / lng / label / entryId / zoom; empty-state UI when no points); zoom clamping (&quot;auto&quot; threaded as &quot;auto&quot;; numeric clamped to [1..18] on both bounds); coordinate validation (drops rows with non-numeric coords + out-of-range lat |lat|&gt;90 + out-of-range lng |lng|&gt;180; empty wpdb result returns the empty-state path); CDN opt-out (gt_disable_third_party_cdn_leaflet=true short-circuits register_assets; gt_leaflet_url_base filter rewrites the CDN base; trailing slash stripped); assets_registered idempotency guard (second render does not re-register with a new url). Strategy: in-file function_exists-guarded shims for add_shortcode, shortcode_atts, esc_html__, wp_generate_password (deterministic), wp_json_encode, wp_register_style/script, wp_enqueue_style/script, wp_add_inline_script; helper gtmaptest_inline_scripts_for_handle() normalizes between the two wp_add_inline_script shapes other test files declare; Mockery-mocked $wpdb for get_row + get_results; Reflection-reset on TC_Map::$instance between tests. Shim suite: 970 tests / 1879 assertions -&gt; 995 tests / 1922 assertions (+25 / +43). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/class-tc-map.php 0% -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.3.6</strong> — <?php _e('Test coverage (slice 43 — pin TC_Elementor at ~100%). Third per-file fill of the post-slice-40 phase. includes/class-tc-elementor.php (107 statements, 0% in the v5.1.8 baseline). New tests/GTElementorTest.php (21 tests / 45 assertions) pins gt_register_gravity_tables_elementor_widget (registers widget instance; idempotent on second call — inner-class declaration guarded by class_exists(&quot;TC_Elementor_Widget&quot;, false)); widget metadata (get_name=&quot;gravity_table&quot;; get_title=&quot;Gravity Table&quot;; get_icon=&quot;eicon-table&quot;; get_categories=[&quot;general&quot;]; get_keywords=[&quot;gravity forms&quot;,&quot;table&quot;,&quot;data&quot;]); register_controls (registers six controls: table_id NUMBER + min=0 + default=0; show_search/filters/pagination default &quot;true&quot;; show_export default OFF; per_page NUMBER); render (editor-mode placeholder path; non-editor early-return when table_id&lt;=0; do_shortcode invocation with attrs; switcher attr threading via === &quot;true&quot; check; per_page omitted when 0/absent, threaded when positive; int-cast on table_id from string &quot;13&quot;; negative table_id early-return; only-set switcher keys threaded). Strategy: WP_INT_TEST=true from bootstrap skips file-load add_action(&quot;elementor/loaded&quot;) wiring; in-file eval()-declared stubs for \Elementor\Widget_Base (with start/add/end controls + get_settings_for_display seam), \Elementor\Controls_Manager (TAB_CONTENT/NUMBER/SWITCHER constants), \Elementor\Plugin ($instance public prop); do_shortcode shim captures the rendered shortcode for assertion; protected methods invoked via Reflection (PHP 8.1+ no setAccessible needed). Shim suite: 949 tests / 1834 assertions -&gt; 970 tests / 1879 assertions (+21 / +45). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/class-tc-elementor.php 0% -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.3.5</strong> — <?php _e('Test coverage (slice 42 — pin TC_Conditional_Formatting_Service at ~100%). Second per-file fill of the post-slice-40 phase. includes/services/class-tc-conditional-formatting-service.php (95 statements, 0% in the v5.1.8 baseline). New tests/GTConditionalFormattingServiceTest.php (43 tests / 70 assertions) pins the full public surface: evaluate (all 7 supported conditions equals / not_equals — case-insensitive + whitespace-trimmed; contains — case-insensitive, false on empty compare; greater_than / less_than — numeric, false on non-numeric; is_empty / is_not_empty — trim()-aware; unknown / missing op returns false; value_column override reads from $row; falls back to rule value when key absent); get_cell_attributes (skips empty-condition rules; empty when no match; background_color via sanitize_hex_color valid threaded + invalid dropped; text_color; bold; italic; combined styles concatenated; css_class exploded on space; sanitize_html_class strips invalid + drops empty; first-match-wins by default; apply_all =&gt; true threads through subsequent matches); get_inline_styles (empty / style=&quot;...&quot;); apply_to_cell (preg_replace injection into first &lt;td...&gt;; pass-through; appends to existing class; adds new class; only modifies first &lt;td&gt;); apply_to_row (empty when no row_level rules; trigger_column drives evaluation; skips unmatched; empty trigger_column / missing row value both fall back to &quot;&quot;; first-match-wins by default; apply_all concatenates; array_unique on class list; cell-level rules filtered out). Strategy: pure logic — no $wpdb; in-file function_exists-guarded sanitize_hex_color (validates #xxx / #xxxxxx) and sanitize_html_class (strips non [A-Za-z0-9_-]) shims; bootstrap provides esc_attr. Shim suite: 906 tests / 1764 assertions -&gt; 949 tests / 1834 assertions (+43 / +70). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/services/class-tc-conditional-formatting-service.php 0% -&gt; ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.3.4</strong> — <?php _e('Test coverage (slice 41 — pin TC_Config_Port_Service at ~100%). First per-file fill of the post-slice-40 phase. includes/services/class-tc-config-port-service.php (86 statements, 0% in the v5.1.8 baseline). New tests/GTConfigPortServiceTest.php (27 tests / 54 assertions) pins the full public surface: export_table (active-row return with version / plugin_version / exported_at; WP_Error on missing row; invalid settings JSON falls back to []; form_id int-cast; exported_at is ISO 8601 UTC); export_to_json (pretty-printed JSON; WP_Error propagation); validate_import (all schema gate WP_Error codes: gt_import_not_array, _missing_version, _version_too_new, _missing_config; accepts current EXPORT_VERSION); import_from_json (gt_import_invalid_json; validate errors propagated; happy-path returns inserted id; form_map remap rewrites form_id at insert; default title fallback &quot;Imported Table&quot;; gt_import_db_error on insert failure; sanitize_text_field strips tags + trims on title; settings round-trips through json_encode; shortcode placeholder [gravity_table id=&quot;__ID__&quot;] on insert then update writes real id; status=&quot;active&quot;; writes to {prefix}gravity_tables; int return type). Strategy: Mockery-mocked $wpdb seams + in-file function_exists-guarded sanitize_text_field / __ shims; bootstrap provides wp_json_encode / is_wp_error / WP_Error / TC_VERSION. Shim suite: 879 tests / 1710 assertions -> 906 tests / 1764 assertions (+27 / +54). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/services/class-tc-config-port-service.php 0% -> ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.3.3</strong> — <?php _e('Bug fix (#1135, slice 40 — class_exists(&quot;GFAPI&quot;) gate). Production bug surfaced during slice 38 coverage. includes/class-tc-merged-table.php guarded GFAPI access via function_exists(&quot;GFAPI&quot;). GFAPI is a class in Gravity Forms, not a function — the gate was always taken in production, and get_entries / get_total_count short-circuited to [] / 0 on every call. Every merged-table view shipped since #82 has been rendering empty. Two-line patch — function_exists(&quot;GFAPI&quot;) → class_exists(&quot;GFAPI&quot;) at both call sites. tests/GTMergedTableTest.php extended with 8 happy-path tests using Mockery::mock(&quot;alias:GFAPI&quot;) + the slice-28 $GLOBALS[&quot;gt_test_admin_override&quot;] TC_Admin seam: get_entries threads fixture rows + stamps _source_table_id; skips unknown table ids; skips form when GFAPI returns WP_Error; passes per_page through as paging[page_size]; paginates the combined rows (page=2 / per_page=2); get_total_count sums GFAPI::count_entries across forms; skips unknown table ids; skips WP_Error counts. The new tests FAIL against pre-slice-40 main (broken gate returns []) and PASS after the fix — proper TDD red/green cycle. Slice-38 function GFAPI() {} workaround shim removed (only there to satisfy the wrong gate). Shim suite: 871 tests / 1693 assertions -> 879 tests / 1710 assertions (+8 / +17). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/class-tc-merged-table.php ~95% -> ~100% (GFAPI inner loops now actually executed under test, not just gate-checked). Closes #1135.', 'tc-data-tables'); ?></li>
                    <li><strong>5.3.2</strong> — <?php _e('Test coverage (slice 39 — pin TC_Popup at ~30%). Eighth (and final) per-file fill of the post-cluster-green phase. includes/class-tc-popup.php (65 statements, 0% in the v5.1.8 baseline). New tests/GTPopupTest.php (11 tests / 26 assertions) pins get_instance() singleton; render_popup_link() pure HTML builder (thickbox anchor + gt-add-entry-trigger id + data-form-id; TB_inline href target + inlineId=gt-add-entry-popup + 700x700; hidden popup container with gt-popup-content class; loading placeholder; int-cast on form_id; localized Add New Entry label; anchor renders before popup container; h2 emitted); enqueue_popup_scripts() early-return on admin pages whose hook does not contain gravity-tables (asset-leak regression guard); constructor wires four add_action calls (admin_enqueue_scripts, wp_enqueue_scripts, wp_ajax_gt_get_form_for_popup, wp_ajax_nopriv_gt_get_form_for_popup). Coverage scope: get_form_for_popup() AJAX handler + enqueue happy path NOT covered (deep mock stack required: check_ajax_referer, current_user_can, wp_send_json_*, GFForm*, GFAPI, add_thickbox, wp_enqueue_script, wp_localize_script, TC_Asset_Enqueue_Gate); future slice can use the Mockery alias technique GTPerformanceTest uses. Shim suite: 860 tests / 1667 assertions -> 871 tests / 1693 assertions (+11 / +26). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/class-tc-popup.php 0% -> ~30%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.3.1</strong> — <?php _e('Test coverage (slice 38 — pin TC_Merged_Table at ~95%). Seventh per-file fill of the post-cluster-green phase. includes/class-tc-merged-table.php (57 statements, 0% in the v5.1.8 baseline). New tests/GTMergedTableTest.php (17 tests / 32 assertions) pins get_entries() early-return on empty source_table_ids; left_join() (match on keys, left-join semantics for unmatched left rows, right_ column prefix, null-keyed left/right both handled, empty inputs, duplicate keys last-write-wins); inner_join() (exclude unmatched left, right_ prefix, null-keyed left/right both excluded, empty inputs, heterogeneous left_key != right_key); get_total_count() early-return on empty source_table_ids. Production bug surfaced as #1135 — class file uses function_exists("GFAPI") instead of class_exists("GFAPI"); gate is always taken, GFAPI inner loops unreachable in production. Filed for follow-up; not patched in this coverage-only slice. Shim suite: 843 tests / 1635 assertions -> 860 tests / 1667 assertions (+17 / +32). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/class-tc-merged-table.php 0% -> ~95% (GFAPI loop bodies blocked by #1135). No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.3.0</strong> — <?php _e('Test coverage (slice 37 — pin TC_Cell_Merge_Service at ~100%). Sixth per-file fill of the post-cluster-green phase. includes/services/class-tc-cell-merge-service.php (50 statements, 0% in the v5.1.8 baseline). New tests/GTCellMergeServiceTest.php (32 tests / 49 assertions) pins all six public statics: validate_merge (rectangular 2x2; rowspan/colspan zero / negative -> invalid_range; single cell -> no_op; overlap, adjacent non-overlapping, partial corner overlap, 1xN horizontal, Nx1 vertical); apply_merge (append + preserve existing + propagate overlap / no_op WP_Errors); unmerge (remove by (row, col) + no-op on missing + reindex via array_values + empty when last removed); render_merged_cell_attrs (empty when both spans 1, rowspan only, colspan only, both); is_covered_cell (anchor not covered, interior cells covered, outside-span not covered, no merges -> false, multiple merges correctly mapped); get_excel_merge_range (A1:B2, C3:E5, A1:D1, A1:A4, Z + AA column roll-over). Shim suite: 811 tests / 1586 assertions -> 843 tests / 1635 assertions (+32 / +49). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/services/class-tc-cell-merge-service.php 0% -> ~100%. Bump-at-9 leading-digit increment — patch (5.2.9 trailing-9) rolls up to minor (5.3.0). No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.2.9</strong> — <?php _e('Test coverage (slice 36 — pin TC_AMP_Compat at ~100%). Fifth per-file fill of the post-cluster-green phase. includes/class-tc-amp-compat.php (41 statements, 0% in the v5.1.8 baseline). New tests/GTAmpCompatTest.php (24 tests / 41 assertions) pins the full public surface: singleton get_instance(); is_amp_request() four-branch detection (no signal / ?amp query param / amp_is_request() / is_amp_endpoint() / ampforwp_is_amp_endpoint()); get_inline_styles() non-empty + targets .gt-table-wrap / .gt-table + preserves @media (max-width: 640px) + excludes AMP-disallowed @import / behaviour: / expression(; get_fallback_table_html() wrap + table id + thead labels with esc_html (XSS regression guard) + row body with esc_html + missing column-id empty cell + missing label empty th + int-cast on table_id; output_amp_inline_css() echoes get_inline_styles(); maybe_dequeue_scripts() dequeues gt-frontend / gt-datatables / datatables exactly when amp. Strategy: AMP plugin function shims declared lazily via eval() under function_exists guards. Shim suite: 787 tests / 1545 assertions -> 811 tests / 1586 assertions (+24 / +41). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/class-tc-amp-compat.php 0% -> ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.2.8</strong> — <?php _e('Test coverage (slice 35 — pin TC_Autoload_Manager at ~100%). Fourth per-file fill of the post-cluster-green phase. includes/class-tc-autoload-manager.php (40 statements, 0% in the v5.1.8 baseline). New tests/GTAutoloadManagerTest.php (19 tests / 29 assertions) pins both public statics: get_autoload_stat (string -> int cast from wpdb->get_var, null / "0" both return 0, all three GT prefixes — gt_ / gravity_tables / gravity-tables — threaded through wpdb->prepare, autoload IN ("yes","on","true","1") filter, wp_options table + COALESCE / SUM(LENGTH), prefix clauses joined with " OR ", esc_like called on every prefix) and optimize_autoload (0 on empty / null get_col, count on happy path, no wpdb->update on empty set, update_option(name, value, false) per match, fallback to wpdb->update with literal [&quot;autoload&quot; =&gt; &quot;no&quot;] / [&quot;option_name&quot; =&gt; name] when update_option returns false, mixed paths both count toward the tally). Strategy: Mockery-mocked wpdb seams + in-file get_option / update_option pair driven via $GLOBALS. Shim suite: 768 tests / 1516 assertions -> 787 tests / 1545 assertions (+19 / +29). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/class-tc-autoload-manager.php 0% -> ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.2.7</strong> — <?php _e('Test coverage (slice 34 — pin TC_Collapsible_Service at ~100%). Third per-file fill of the post-cluster-green phase. includes/services/class-tc-collapsible-service.php (32 statements, 0% in the v5.1.8 baseline). New tests/GTCollapsibleServiceTest.php (20 tests / 40 assertions) pins all 5 public statics: is_enabled / is_default_collapsed truthy + falsy settings reads; get_toggle_button_html aria + chevron + table-id threading + esc_html title (XSS regression guard); get_body_wrapper_attrs id + class + hidden attr; get_inline_script substitution + localStorage key prefix + prefers-reduced-motion guard + DataTable columns.adjust on expand + try/catch around localStorage for private-browsing mode. Shim suite: 748 tests / 1476 assertions -> 768 tests / 1516 assertions (+20 / +40). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/services/class-tc-collapsible-service.php 0% -> ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.2.5</strong> — <?php _e('Test coverage (slice 33 — pin TC_Dirty_State at ~100%). Second per-file fill of the post-cluster-green phase. TC_Dirty_State (17 statements, 0% in the v5.1.8 baseline) is the smallest 0% file in includes/ per the slice 27 inventory. New tests/GTDirtyStateTest.php (11 tests / 30 assertions) pins get_instance() singleton, get_script() content (every wired event handler including the #159 row-deletion regression guard, save-button selectors, beforeunload lifecycle), and enqueue() four-branch coverage (no screen / wrong screen / handle registered -> wp_add_inline_script / handle missing -> direct &lt;script&gt; echo fallback). Bootstrap does not stub get_current_screen / wp_script_is / wp_add_inline_script, so the test declares lightweight in-file shims keyed by $GLOBALS and drives each branch independently. Shim suite: 737 tests / 1446 assertions -> 748 tests / 1476 assertions (+11 / +30). Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Coverage delta: includes/class-tc-dirty-state.php 0% -> ~100%. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.2.4</strong> — <?php _e('Security (#1069 — fail-closed permission gate + table-access checks + cap tightening on the AJAX boundary). Closes the five authorization findings from the #1069 audit plus one trailing parity site that slice 31 (#1073 / v5.2.3) left behind in TC_Entry_Service. (1) export_table used current_user_can(read) — every logged-in subscriber could export any form. Replaced with a new dedicated export_gravity_tables capability registered in TC_Capabilities_Service, plus checkTableAccessPermission(form_id, table_id) before any data access so export-capable users cannot enumerate rows from a table they do not have explicit access to. (2) get_filter_suggestions only nonce-checked — any nopriv visitor could enumerate distinct field values for any form_id, including admin-only forms. Added checkTableAccessPermission(form_id) after parameter validation. (3) get_lookup_options had the same shape as #2; same fix. (4) upload_file did no capability check beyond the nonce, so a logged-out visitor with a public-page nonce could POST a file and have it written to the WP uploads directory (subject to wp_check_filetype). Added current_user_can(upload_files) after the nonce check; bails with gt_access_denied/403. (5) checkTableAccessPermission silently returned (allowed access) when the table config row was missing or the JSON settings blob was malformed — orphan form_ids (table config deleted but a shortcode still referencing the form) bypassed the role gate entirely. Both fail-open branches now wp_send_json_error([code => gt_access_denied], 403). The existing role-denied branch also gains the canonical gt_access_denied code for consistency. (6) class-tc-entry-service.php:267-269 routed REMOTE_ADDR / HTTP_REFERER / HTTP_USER_AGENT through gt_request_server_text for slice 31 (#1073) parity — submit_new_entry already used this pattern. New capability: export_gravity_tables is auto-granted to administrators on activation and on the version-upgrade path (ensure_capabilities_for_version); manageable through the existing role-grant admin UI alongside View Tables / Edit Tables. Katie-style customers editor / shop-manager role grants keep working. Customer-visible behaviour change: the fail-closed flip on checkTableAccessPermission means customers with orphan form_ids (table config row deleted but a [gravity_table] shortcode still referencing the form) will now see 403 gt_access_denied instead of silently rendering. This is the correct behaviour (orphan = no explicit config = no access). Repair path is unchanged — admins recreate the table config in the table-builder UI and the shortcode resumes working with the configured role gate enforced. New tests/GTAuthorizationGatesTest.php pins the contract via static scans on class-tc-ajax.php + class-tc-capabilities-service.php + class-tc-entry-service.php (21 tests / 46 assertions). Method-body slicer bounded by the next sibling method declaration to avoid false-passes from neighbour-handler bleed. Shim suite: 716 -> 737 tests, 1400 -> 1446 assertions, 0E/0F maintained. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Patch bump 5.2.3 -> 5.2.4 per the bump-at-9 rule. Audit: github.com/TableCrafter/gravity-tables/issues/1069.', 'tc-data-tables'); ?></li>
                    <li><strong>5.2.3</strong> — <?php _e('Security (#1073 — wp_unslash + sanitize_text_field + per_page cap on the AJAX boundary). The audit on #1073 found four input-sanitization hygiene gaps in includes/class-tc-ajax.php. This release closes all four: (1) submit_new_entry read $_SERVER[REMOTE_ADDR] / HTTP_REFERER / HTTP_USER_AGENT raw before stuffing the values into the GFAPI entry array. Now route through a new gt_request_server_text helper that wraps sanitize_text_field(wp_unslash(...)) and returns the configured default verbatim when the key is missing (proxy environments where REMOTE_ADDR can legitimately be absent). (2) submit_new_entry per-field POST read ($value = $_POST[$input_name]) now runs through wp_unslash before sanitize_field_value, so wp_magic_quotes-added backslashes do not survive into storage. Cardinal regression: O\'Brien round-trips as O\'Brien, not O\\\'Brien. (3) The four legacy stripslashes call sites flagged by the audit (sort_stack at line 1366, drilldown_filters at 1387, filters at 5386, columns at 5387) swap to wp_unslash — wp_magic_quotes-aware and the WP-idiomatic AJAX-boundary choice. (4) get_entries now clamps per_page to [1, 200] via max(1, min(200, ...)), mirroring the TC_Entry_Repository::find and REST per_page idiom. A hostile client can no longer request per_page=999999 and starve the DB. The new helper lives in includes/helpers-request.php and is wired into the plugin bootstrap (tablecrafter.php) alongside the other shared helpers from #1075 / #1076. class-tc-ajax.php also require_once\'s the helper defensively so legacy-runner tests that load the AJAX class directly (test-issue-72 / -78 / -305 / -379) get the dependency too. New tests/GTInputSanitizationTest.php pins the contract (21 tests / 36 assertions): helper unit tests for missing-key default, slash strip, tag strip, normal IP round-trip, apostrophe round-trip; plus static-scan invariants on includes/class-tc-ajax.php proving the four audit sites are patched (no raw $_SERVER reads, gt_request_server_text called 3x, wp_unslash on $_POST[$input_name], zero stripslashes calls left, wp_unslash on sort_stack / drilldown_filters / filters / columns, per_page clamped to [1, 200]). tests/test-issue-568-slice4-drilldown-persistence.php AC#2 updated to assert the wp_unslash idiom. Shim suite: 695 -> 716 tests, 1364 -> 1400 assertions, 0E/0F maintained. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Patch bump 5.2.2 -> 5.2.3 per the bump-at-9 rule. No customer-facing behaviour change. Legitimate inputs round-trip identically (O\'Brien stays O\'Brien; per_page=25 / 50 / 100 still work). The clamp only affects pathological per_page=999999 / per_page=-1 cases the audit called out. Hardening only — no exploitable injection path was demonstrated, but the AC bar "every superglobal read needs wp_unslash" is now met. Audit: github.com/TableCrafter/gravity-tables/issues/1073.', 'tc-data-tables'); ?></li>
                    <li><strong>5.2.2</strong> — <?php _e('Security (#1076 — AES-256-CBC encryption at rest for cloud-storage + AI provider keys + REST allowlist). The audit on #1076 found four secrets-storage findings beyond the gold-standard Airtable PAT (which was already encrypted via TC_Airtable_Credential_Service). This release closes all four: (1) Cloud-storage OAuth tokens (OneDrive / SharePoint / Google Drive) at class-tc-cloud-storage.php:300 were stored plaintext in wp_options; store_token now routes through gt_encrypt_secret (AES-256-CBC keyed off AUTH_KEY + gt_cloud_tokens salt). autoload=false invariant preserved. New get_token helper handles both encrypted envelopes and legacy plaintext on read, auto-migrating on the next save so existing customer installations keep working. (2) AI provider api_keys (OpenAI / Anthropic / Gemini) had no encryption path; added gt_ai_settings_encrypt / _decrypt helpers; TC_AI_Provider_Registry::active now decrypts before handing the key to the provider constructor. Legacy plaintext passes through. (3) REST get_table settings strip at class-tc-rest-api.php:218 was a by-removal denylist (unset webhook_url, notify_emails) — drift-prone since any new sensitive field would leak by default. Flipped to explicit allowlist via gt_rest_filter_safe_settings with a gt_rest_settings_safe_keys filter for opt-ins and an always-sensitive deny gate that strips secrets even if a filter callback tries to opt them in. (4) Bulk-migration export bundle at tablecrafter.php:611 streamed encrypted token blobs verbatim; wired through gt_bundle_strip_secrets which drops sensitive keys and appends a _stripped_secret_keys sentinel so importers can prompt the admin to re-enter credentials on the new site. Verified safe (no code changes): Notion already encrypted via TC_Airtable_Credential_Service in class-tc-sanitization-service.php:266; webhook signing has no in-runtime store (only Freemius CLI signing under tools/); logger redaction — no current call site passes wp_remote_* Authorization headers to TC_Logger::log. Shared helper extracted to includes/helpers-secrets.php with bit-identical key derivation to the Airtable PAT path. The encryption envelope carries a gt_enc_v1: sentinel; reads without the prefix are treated as legacy plaintext (backward-compat). Five new test files / 40 new tests: GTSecretEncryptionTest, GTCloudStorageTokenEncryptionTest, GTAiSettingsEncryptionTest, GTRestSettingsAllowlistTest, GTMigrationBundleSecretStripTest. Shim suite: 655 -> 695 tests, 1273 -> 1364 assertions, 0E/0F maintained. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Patch bump 5.2.1 -> 5.2.2 per the bump-at-9 rule. No customer-facing behaviour change for any working OAuth connection. Audit: github.com/TableCrafter/gravity-tables/issues/1076.', 'tc-data-tables'); ?></li>
                    <li><strong>5.2.1</strong> — <?php _e('Security (#1075 — shared gt_validate_outbound_url() SSRF helper closes enforcement gaps). The audit on #1075 found four wp_remote_*() call sites that did not route through the existing SSRF gate (TC_JSON_Source_Service::is_safe_url), plus one regex-fall-through case in the Google Sheets fetcher. This release promotes the gate to a free-standing helper (gt_validate_outbound_url() in includes/helpers-url.php) and routes every outbound HTTP call site through it: class-tc-auto-import.php (auto-import URL fetch), class-tc-xml-source.php (remote XML URL), class-tc-ajax.php (per-table webhook POST), class-tc-google-sheets.php (CSV regex fall-through), class-tc-json-source-service.php (is_safe_url() now delegates), class-tc-json-push-engine.php (switched to the shared helper). Each new gate is marked with // #1075 for audit traceability. Hardening additions over the original inline gate: DNS-rebinding re-check (gethostbyname + re-apply private / loopback / link-local guard on the resolved IP — catches public hostnames whose A-record points at 169.254.169.254 cloud metadata!); explicit 0.0.0.0 reject; IPv6 literal bracket-strip; parameterized allowed-schemes list; TC_PHPUNIT_SHIM-gated $GLOBALS[gt_test_resolver] seam so unit tests inject deterministic IPs without live DNS. New tests/GTValidateOutboundUrlTest.php pins the helper contract (33 tests / 33 assertions): every accepted shape, every rejected scheme, every rejected range (loopback / RFC1918 / link-local / IPv6 ULA / 0.0.0.0 / mDNS .local), DNS-rebinding attack scenarios, fail-open-when-DNS-unavailable. tests/test-issue-980-json-source-http-fetcher.php AC#4 / AC#5 / AC#6 source-regex checks updated to point at the helper file (contract has moved); new AC#11 pins the DNS-rebinding re-check. Shim suite: 622 -> 655 tests, 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. No customer-facing behaviour change for legitimate public URLs. Reject paths return WP_Error(gt_outbound_url_rejected) or bool false; every caller already branches on is_wp_error() or truthy. Audit: github.com/TableCrafter/gravity-tables/issues/1075#issuecomment-4583955144.', 'tc-data-tables'); ?></li>
                    <li><strong>5.2.0</strong> — <?php _e('Test infra (#667 slice 28 — TC_Admin / TC_Logger / TC_Debug PHPUnit-shim injection seams; closes #1087, undefers #72 / #90). Resolves the autoload-conflict deferral slice 14 introduced. Three production singletons (includes/class-tc-admin.php, includes/class-tc-logger.php, includes/class-tc-debug.php) gain a TC_PHPUNIT_SHIM-gated override branch on get_instance(): $GLOBALS[gt_test_admin_override] / $GLOBALS[gt_test_logger_override] / $GLOBALS[gt_test_debug_override]. Production TC_Admin gains static props $should_throw, $throw_message, $should_return_false; production TC_Logger gains $last_log (all neutral defaults). tests/test-issue-72-insert-row-errors.php and tests/test-issue-90-safe-save.php wrap stub-class redeclarations in if (!class_exists) guards and install lightweight anon-class overrides under the shim. tests/PHPUnitShimTest.php drops the #72 / #90 allowlist entries and adds a buffer-baseline fix so production handlers gt_clean_output_buffers() loop cannot cascade-fail the shim wrapper. tests/PHPUnitShimAllowlistTest.php contract flipped — now asserts the entries are NOT present. New tests/GTAdminLoggerSeamTest.php pins the contract with 16 tests / 19 assertions: shim-constant gate, production-on-no-override, override-honored, every new static prop declared with default and writable. Production safety: every branch is gated on TC_PHPUNIT_SHIM, defined only by the test harness; production WordPress takes the byte-identical pre-slice path. Shim suite: 607 tests / 1219 assertions -> 622 tests / 1240 assertions, 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Leading-digit bump 5.1.9 -> 5.2.0 per the bump-at-9 rule. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.1.9</strong> — <?php _e('Test coverage (#667 slice 27 — pin 100% for includes/exceptions/). First per-file coverage fill of the post-cluster-green phase. Three domain exception classes (TC_Database_Exception, TC_Permission_Exception, TC_Validation_Exception) that sat at 0% coverage in the v5.1.8 baseline now have dedicated unit tests pinning their full public surface (12 tests, 34 assertions). tests/GTExceptionsTest.php is the template other per-class slices in Phase 5 of the 100% coverage initiative can mirror. Suite: 595 tests / 1185 assertions -> 607 tests / 1219 assertions. Stays 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.1.8</strong> — <?php _e('Test infra (#667 slice 26 — wire #560-s3, FINAL wpdb cluster close-out; suite 0E/0F). Closes the last remaining PHPUnit shim failure. The suite is now fully green: 0 errors, 0 failures. Same 4-step pattern slices 22/23/24/25 established across the wpdb-cluster: pack the tests column config into $row->settings JSON + seed the slice 21 get_row fixture. AC#4 CSV emission (header + 3 data rows + newline count) now passes. The wpdb-cluster closes out across six slices: slice 21 v5.1.3 seam itself + #503-s2 partial, slice 22 v5.1.4 #503-s2 full, slice 23 v5.1.5 #503-s3, slice 24 v5.1.6 #519-s2, slice 25 v5.1.7 #560-s2, slice 26 v5.1.8 #560-s3. The PHPUnit shim now agrees with tools/test-all.sh on every test-issue file the allowlist does not defer; coverage attribution flows cleanly to includes/, templates/, admin/ on every PR via the shim. The 100% coverage initiatives next phase (per-file coverage fill, #668-#672) can start from a green baseline. Shim suite: 0E/1F -> 0E/0F. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.1.7</strong> — <?php _e('Test infra (#667 slice 25 — wire #560-s2 to slice 21 wpdb seam + add_action adapter + AC#7 unique-id cache bypass). Three coordinated changes in tests/test-issue-560-slice2-rest-endpoint.php. (1) _gt560s2_registered_hooks() adapter folds both add_action storage shapes ($_gt_test_added_actions local stub + $_gt_test_action_callbacks bootstrap) into a single hook-name list. AC#2 rest_api_init hook registered uses it. (2) _gt560s2_seed_table() packs config into $row->settings JSON + registers slice 21 wpdb get_row fixture. (3) AC#7 uses table_id=8 to bypass production TC_Admin::get_table()s in-method static cache. Shim suite: 0E/2F -> 0E/1F. Cleared end-to-end: #560-s2 (all 8 sub-failures gone). Three of four wpdb-cluster files now closed (#503-s2 in slice 22, #503-s3 in slice 23, #519-s2 in slice 24, #560-s2 in slice 25). One remaining: #560-s3. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. No customer-facing runtime change — single test-issue file modified; production code byte-identical.', 'tc-data-tables'); ?></li>
                    <li><strong>5.1.6</strong> — <?php _e('Test infra (#667 slice 24 — wire #519-s2 + bootstrap wp_mkdir_p fix + cache-aware AC handling). Four coordinated changes. (1) tests/bootstrap.php wp_mkdir_p() now actually mkdirs the directory recursively (idempotent on existing dirs) instead of the pre-slice hard-coded return true. The lie was the root cause of 5 of 13 sub-failures — production ensure_dir → wp_mkdir_p returned true, then fopen failed because the directory was never created. (2) _gt519s2_seed_table packs config into $row->settings JSON + registers the slice 21 get_row fixture so production TC_Admin::get_table() resolves the seeded row. (3) AC#9 PhpSpreadsheet-absent fallback skips cleanly under the shim with defined(TC_PHPUNIT_SHIM) && class_exists(...) because composer autoload loads PhpSpreadsheet under the shim. (4) AC#10 uses table_id=8 instead of table_id=7 to bypass production TC_Admin::get_table static $cache (AC#5 populated it with the Loads row). New tests/BootstrapWPMkdirPTest.php pins the contract with 4 tests / 8 assertions. Shim suite: 0E/3F -> 0E/2F. Cleared end-to-end: #519-s2 (all 13 sub-failures gone). Three of four wpdb-cluster files now closed (#503-s2 in slice 22, #503-s3 in slice 23, #519-s2 in slice 24). Two remaining: #560-s2, #560-s3. Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. No customer-facing runtime change — the bootstrap stub is guarded with if (!function_exists) and production WordPress declares the real wp_mkdir_p long before tests/bootstrap.php would ever run (and in production tests/bootstrap.php does not run at all).', 'tc-data-tables'); ?></li>
                    <li><strong>5.1.5</strong> — <?php _e('Test infra (#667 slice 23 — wire #503-s3 to slice 19 + slice 21 seams + add_action adapter). Three coordinated changes in tests/test-issue-503-slice3-writes-and-notice.php. (1) Premium-seam mirror: 12 inline $GLOBALS[_gt_test_is_premium] writes mirrored to $GLOBALS[gt_test_is_premium] (slice 19 production seam key). Without the mirror every premium assertion resolves to FALSE under the shim and dispatch returns gt_ability_premium_required before the real handler runs. (2) add_action adapter (_gt503s3_registered_hooks): folds both storage shapes — local stub $_gt_test_added_actions ({hook,cb,prio}) and bootstrap $_gt_test_action_callbacks ([hook][]=cb) — into a single hook-name list. The three AC#5/AC#6 hook-registered assertions now call the helper. (3) wpdb fixture seeds: AC#3 create_row + AC#7 list_tables register get_row / get_results SQL-pattern fixtures so production TC_Admin::get_table / get_tables resolve against the same row the test was already writing. Shim suite: 0E/4F -> 0E/3F. Cleared end-to-end: #503-s3 (all 14 sub-failures gone). Two of the four wpdb-cluster files now closed end-to-end (#503-s2 in slice 22, #503-s3 in slice 23). Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. No customer-facing runtime change — single test-issue file modified; production code byte-identical.', 'tc-data-tables'); ?></li>
                    <li><strong>5.1.4</strong> — <?php _e('Test infra (#667 slice 22 — wire #503-s2 AC#5/AC#6/AC#7 to the slice 21 wpdb seam). Three describe-block-level wire-ups in tests/test-issue-503-slice2-read-wire.php. AC#5 packs the tests column config into row->settings as JSON (production TC_Admin::get_table_config reads row->settings via json_decode, not admin->configs) and seeds the slice 21 get_row fixture. AC#6 reuses the same get_row fixture so the handler reaches the class_exists("GFAPI") check (GFAPI is absent in the test env → handler returns gt_ability_unavailable as expected). AC#7 seeds the slice 21 get_results fixture so the list_tables callback sees the single fixture row. Each it() block resets its fixtures on exit. Shim suite: 0E/5F -> 0E/4F. Cleared end-to-end: #503-s2 (the entire file now passes under the PHPUnit shim — the remaining 4 sub-failures from slice 21 are gone). Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. No customer-facing runtime change — single test-issue file modified; production code byte-identical.', 'tc-data-tables'); ?></li>
                    <li><strong>5.1.3</strong> — <?php _e('Test infra (#667 slice 21 — wpdb get_results/get_row SQL-pattern fixture seam + #503-s2 AC#4 wire-up). TC_Bootstrap_WPDB_Stub::get_results() / get_row() now support per-test SQL-pattern fixtures gated by $GLOBALS[gt_test_wpdb_fixtures]. Fixtures are scanned top-to-bottom, first regex match wins, no match falls through to the slice-12 default (empty array / null). test-issue-503-slice2-read-wire AC#4 seeds the fixture right before invoking the dispatch + resets it on exit so AC#5 / AC#6 fixtures dont bleed in. This is the unblocker for the five remaining shim failures (#503-s2, #503-s3, #519-s2, #560-s2, #560-s3) — they all share the pattern of declaring a local in-memory stub but having production code read from $wpdb. New tests/BootstrapWPDBResultsFixtureSeamTest.php pins the contract with 7 tests / 9 assertions: default behavior preserved, pattern match on both methods, fall-through on no match, first-match-wins. Shim suite: 0E/5F -> 0E/5F (count steady; substance moved forward — #503-s2 went from 8 sub-failures to 4, and the seam unblocks per-AC wire-ups across the remaining 4 files). Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. No customer-facing runtime change — the seam lives entirely inside tests/bootstrap.php TC_Bootstrap_WPDB_Stub class.', 'tc-data-tables'); ?></li>
                    <li><strong>5.1.2</strong> — <?php _e('Test infra (#667 slice 20 — wp_send_json_*() throw-on-call seam + #496 wire-up). tests/bootstrap.php wp_send_json_success() / wp_send_json_error() stubs now throw RuntimeException AFTER storing the payload when $GLOBALS[gt_test_send_json_throws] is truthy. Default behavior (global unset or false) stays slice-8-identical (store-only, no throw) so the 5 in-tree tests that depend on inspecting $GLOBALS[gt_test_response] after the handler returns naturally still pass. test-issue-496-ai-column-type-detection sets the global at the top of its AJAX describe block so under the PHPUnit shim the bootstrap stub mirrors the throw-on-call behavior the tests own function_exists-guarded stub provides under tools/test-all.sh. New tests/BootstrapWPSendJsonThrowSeamTest.php pins the contract with 6 tests / 8 assertions: default no-throw both stubs, global=true throws with correct exception message identifying which function fired, global=true still stores payload before throwing, global=false treated the same as unset (only explicit true opts in). Shim suite: 0E/6F -> 0E/5F. Cleared end-to-end: #496 (both "must short-circuit via wp_send_json_error" sub-failures gone). Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. No customer-facing runtime change — the bootstrap stubs are guarded with if (!function_exists) so production WordPress, which declares the real wp_send_json_*() long before tests/bootstrap.php would ever run, is unaffected (and in production tests/bootstrap.php does not run at all).', 'tc-data-tables'); ?></li>
                    <li><strong>5.1.1</strong> — <?php _e('Test infra (#667 slice 19 — gt_is_premium() PHPUnit-shim test seam). includes/helpers-license.php gt_is_premium() now consults $GLOBALS[gt_test_is_premium] when TC_PHPUNIT_SHIM is defined. Production callers (where the constant is never defined) take the byte-identical pre-slice Freemius-backed path (wgt_fs is_premium / is_paying / can_use_premium_code). array_key_exists (not isset) so an explicit false value also takes effect. Slice 15 had established the same kind of seam for wgt_fs() itself via $GLOBALS[gt_test_fs_override]; slice 19 adds a complementary narrower seam so test files that only need to flip the premium boolean do not have to build a fake Freemius object. New tests/GTIsPremiumSeamTest.php pins the contract with 4 tests / 5 assertions: function still exists, global=true under shim returns true, global=false under shim returns false, global unset under shim falls through to Freemius. Shim suite: 0E/7F -> 0E/6F. Cleared end-to-end: #495 (all 3 sub-failures gone — "must resolve a provider", "must implement the interface", "active() returns the configured provider on premium plan"). Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. No customer-facing runtime change — the seam is gated on TC_PHPUNIT_SHIM which is only defined by tests/PHPUnitShimTest.php, never in production WordPress.', 'tc-data-tables'); ?></li>
                    <li><strong>5.1.0</strong> — <?php _e('Test infra (#667 slice 18 — wp_upload_dir() PHPUnit-shim test seam + #479 wire-up). tests/bootstrap.php wp_upload_dir() stub now reads $GLOBALS[_gt_test_upload_basedir] when set (same seam already used by test-issue-519-slice2), mkdirs basedir on demand, and returns the standard WP baseurl + error keys. test-issue-479-lfi-path-guard seeds the global right after its sandbox mkdir so under the PHPUnit shim TC_Path_Guard / TC_XML_Source resolve paths against the same directory the test wrote feed.xml to. Under tools/test-all.sh the tests own function_exists-guarded wp_upload_dir() declaration still wins; under the shim the bootstrap stub is the only one that ever runs. New tests/BootstrapWPUploadDirTest.php pins the contract with 4 tests / 7 assertions: default basedir, override-via-global, return-shape keys, on-demand mkdir. Shim suite: 0E/8F -> 0E/7F. Cleared end-to-end: #479 (all 18 assertions match the standalone runner). Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. Leading-digit bump 5.0.9 -> 5.1.0 per the bump-at-9 rule. No customer-facing runtime change — the seam is gated on the global which only the test harness sets, and the bootstrap stub itself is guarded with if (!function_exists) so production WordPress, which declares wp_upload_dir() long before tests/bootstrap.php would ever run, is unaffected (and in production tests/bootstrap.php does not run at all).', 'tc-data-tables'); ?></li>
                    <li><strong>5.0.9</strong> — <?php _e('Test infra (#667 slice 17 — _gt_test_actions_fired structured-entry shape). tests/bootstrap.php do_action() stub now writes both action-tracking globals in the structured [tag/hook, args] shape instead of writing gt_test_actions structured and _gt_test_actions_fired flat. Slice 8 (v5.0.1) had introduced the structured gt_test_actions storage for test-issue-78/-90 and kept _gt_test_actions_fired as a flat hook-name list. The only in-tree consumer of _gt_test_actions_fired — test-issue-550-page-cache-invalidation — reads it as structured entries (array_column($g, "tag") and foreach $entry["tag"]/["args"]). Under the PHPUnit shim that mismatch surfaced as TypeError: Cannot access offset of type string on string across 6 of #550 sub-assertions, including all 4 AC#1/AC#2 cache-purge integration cases. Aligning the stub with the only consumer eliminates the adapter the test would otherwise need and makes the shim agree with tools/test-all.sh on this file. New tests/BootstrapDoActionShapeTest.php pins BOTH storages shape with 4 tests / 9 assertions so a future "simplify the stub" pass cannot silently regress either consumer. Shim suite: 0E/9F -> 0E/8F. Cleared end-to-end: #550 (6 of 8 sub-failures gone; the remaining 2 litespeed_purge_post / w3tc_flush_post assertions still need their own action stubs in a future slice but no longer crash the file). Legacy runner 523/523 + 2 allowed-fail green. Vitest 957/957 green. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.0.8</strong> — <?php _e('Test infra (#667 slice 16 — WP time constants + error_log suppression + #560 guards). Three coordinated changes. (1) tests/bootstrap.php adds the six WordPress time constants — MINUTE_IN_SECONDS through YEAR_IN_SECONDS with canonical WP values (MONTH = 30 days, YEAR = 365 days). Production cache-TTL paths reference HOUR_IN_SECONDS extensively; without these defines tests crashed with Undefined constant. (2) error_log() redirected to /dev/null under the shim so production diagnostics dont leak into PHPUnit subprocess stderr where they would be wrapped as exceptions. (3) test-issue-560-slice2 AC#4 and AC#6 reads guarded with null-coalesce so assertion failures stay as failures instead of becoming convertWarningsToExceptions-promoted errors. New tests/BootstrapWPTimeConstantsTest.php pins all six constants. Shim suite: 0E/9F -> 0E/9F (count steady; substance moved forward — #519, #560-s2, #560-s3 now run past the time-constants wall into the assertion phase). No customer-facing runtime change — time constants match WP canonical values.', 'tc-data-tables'); ?></li>
                    <li><strong>5.0.7</strong> — <?php _e('Test infra (#667 slice 15 — wgt_fs PHPUnit-shim test seam + #502 rework). Production tablecrafter.php wgt_fs() now consults $GLOBALS[gt_test_fs_override] when TC_PHPUNIT_SHIM is defined. Production callers (where the constant is never defined) follow the byte-identical pre-slice code path and never read the override — the gate is the safety guarantee. test-issue-502 reworked to install Freemius stubs via the seam and to read action callbacks from both legacy and shim canonical storages so the same test file passes under both runners. New tests/WgtFsTestSeamTest.php pins the seam contract with 3 tests / 4 assertions. Shim suite: 0E/10F -> 0E/9F. Cleared end-to-end: #502 (4 sub-failures). No customer-facing runtime change — the seam is gated on TC_PHPUNIT_SHIM which is only defined in the test harness, never in production WordPress.', 'tc-data-tables'); ?></li>
                    <li><strong>5.0.6</strong> — <?php _e('Test infra (#667 slice 14 — defer #72 + #90 to allowlist). Extends PHPUnitShimTest skip allowlist with a second category — production-class autoload conflict. Both test-issue-72-insert-row-errors and test-issue-90-safe-save redeclare TC_Admin / TC_Logger as fixture-controlled stubs (with static props like $should_throw, $throw_message, $last_log that production classes lack). Under the legacy runner that works; under the PHPUnit shim bootstrap.php autoloads the full plugin first and the redeclare fatals. Both tests still pass under tools/test-all.sh; production code stays covered. Proper fix needs test-friendly injection seams on the production singletons — tracked by issue #1087. New PHPUnitShimAllowlistTest pins the allowlist contract with 3 tests / 8 assertions. Shim suite: 2E/10F -> 0E/10F — errors fully cleared this session. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.0.5</strong> — <?php _e('Test infra (#667 slice 13 — is_admin relocation). Removed function is_admin() declaration from tests/bootstrap.php and added it to tests/helpers/stubs.php with if (!function_exists) guard. GTPerformanceTest builds WP_Mock::userFunction(is_admin, ...) which delegates to Patchwork; Patchwork refuses to intercept functions already declared at bootstrap-parse time (DefinedTooEarly). Slice 8 had added is_admin to bootstrap to fix #78; that introduced the GTPerformanceTest regression. The relocation mirrors the pattern slice 8 established for current_user_can. Test-issue files that require_once helpers/stubs.php still get a callable default; Patchwork-using tests register interception first. New tests/BootstrapIsAdminPatchworkTest.php pins the contract with 2 tests / 5 assertions. Shim suite: 3E/10F -> 2E/10F. Cleared end-to-end: GTPerformanceTest. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.0.4</strong> — <?php _e('Test infra (#667 slice 12 — $wpdb mock). tests/bootstrap.php now declares a minimal TC_Bootstrap_WPDB_Stub class and binds an instance to $GLOBALS[wpdb] before the plugin loads. Exposes the minimum-viable wpdb interface (prefix=wp_, prepare/get_row/get_results/get_var/get_col/insert/update/delete/replace/query, last_error, insert_id) with safe no-op defaults — single-row reads return null, collection reads return [], writes return 1. Tests that need fixture behavior (GTPerformanceTest builds a Mockery mock, test-issue-550 declares its own TC_Test_WPDB) overwrite $GLOBALS[wpdb] after bootstrap so per-test fixtures cleanly win. Class name namespaced under "Bootstrap" to avoid collision with the unguarded TC_Test_WPDB declarations in several test-issue files. Also adds OBJECT / OBJECT_K / ARRAY_A / ARRAY_N constants. New tests/BootstrapWPDBStubTest.php pins the contract with 11 tests / 12 assertions. Shim suite: 7E/6F -> 3E/10F. Cleared 4 errors (prefix-null cluster: #503-s2, #519-s2, #560-s2, #560-s3) — those four files migrated to failures, assertions now visible per-test. #503-s3 sub-failures 15 -> 14. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.0.3</strong> — <?php _e('Frontend (#1080): Add New Entry button rejoins the toolbar row. Previously rendered as its own row between the toolbar (Export / Print / Undo / Redo) and the table header with visible vertical gap above and below — the button\'s CSS rule margin-left:auto was written for an inline-flex-row context that no longer existed after the #780 hoist out of the filters wrapper. Now lives inside .gt-table-controls (after the bulk-actions block) so it pushes inline-right next to Export / Print as designed. New tests/AddEntryButtonInsideTableControlsTest.php pins the structural invariant. Existing AddEntryButtonNotNestedInFiltersTest (the #780 regression guard) still passes. Browser-verified on /admin-loads-hauled/: button in same row as Export and Print, zero console errors.', 'tc-data-tables'); ?></li>
                    <li><strong>5.0.2</strong> — <?php _e('Test infra (#667 slices 9 + 10 bundled). Slice 10: tests/bootstrap.php adds five WP core-function stubs the PHPUnit shim subprocess needs before any tests/test-issue-*.php loads — wp_cache_get / wp_cache_set / wp_cache_delete (no-op object cache, signatures mirror tests/helpers/stubs.php), wp_remote_request (returns WP_Error(http_request_failed, ...) so production is_wp_error() guards short-circuit identically to a real network failure), and wp_json_encode (delegates to json_encode with WP\'s $depth default). New tests/BootstrapWPStubsTest.php pins the contract with 5 assertions. Slice 9: #452 chart-refresh AC#4 assertion now also checks $_gt_test_action_callbacks[wp_footer] for the slice-5 add_action path; #922 admin-vanilla.js test rewired to glob admin/js/*.js + assets/js/admin/*.js after #842 split. Shim suite: 4E/10F -> 7E/6F (failures -4; +3 errors are same files migrating past the wp_cache_get wall to a $wpdb null surface that is the next slice). Cleared end-to-end: #613 phase 2. Migrated past wall: #519, #560 slice 2, #560 slice 3. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.0.1</strong> — <?php _e('Test infra (#667 slice 8 bundled): PHPUnit shim compatibility cluster. tests/bootstrap.php do_action now populates $GLOBALS[gt_test_actions] in the {hook, args} shape that stubs.php established. wp_send_json_success / wp_send_json_error dual-write to TC_Mock_Response, $gt_test_response, $gt_454_response — one stub satisfies all three test conventions in use. Adds is_admin() stub; deliberately omits current_user_can so Patchwork-based tests (#305) can intercept via WP_Mock::userFunction. Test file #76 drops PHP 8.5-deprecated setAccessible(true). Test file #78 wraps TC_Debug / TC_Logger / TC_Admin stubs with if (!class_exists). Shim suite: 7E/11F -> 5E/11F + 2 risky-pass. Cleared: #76 #78 #305 #454. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>5.0.0</strong> — <?php _e('Versioning reset + Test infra (#667 slice 5 bundled). New rule: bundle multiple PRs per release; bump only when a digit would otherwise become 10 (5.0.0 -> 5.0.1 -> ... -> 5.0.9 -> 5.1.0). Supersedes the 4.226.x patch-per-PR cadence. Bundled slice 5: PHPUnit bootstrap apply_filters / add_filter / do_action / add_action realistic stubs declared BEFORE WP_Mock::bootstrap(). WP_Mock\'s function_exists guard then skips its no-op pass-through versions, so test-issue files\' filter-override assertions agree with the legacy custom runner. Honors both filter conventions in use (single-override $GLOBALS[\'_gt_test_filters\'] and callback list $GLOBALS[\'__filters\'] with per-test resets). Shim failures: 23 -> 11. No customer-facing runtime change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.226.3</strong> — <?php _e('Test infra (#667 slice 4 — patch): PHPUnit bootstrap WP_Error + is_wp_error stubs. tests/bootstrap.php now declares a WP-realistic WP_Error class (3-arg constructor: code, message, data) and a real is_wp_error that returns instanceof. Previously is_wp_error was a no-op `return false` that broke every AI test asserting WP_Error from provider stubs. Also guarded tests/helpers/stubs.php WP_Error against redeclaration. Cleared 8 sub-failures across #495 (6 -> 3) and #496 (8 -> 3). Total shim sub-failures: 73 -> 66. Strict TDD: 5 ACs / 12 assertions, 10 RED -> 12 GREEN. PHP suite 523 files.', 'tc-data-tables'); ?></li>
                    <li><strong>4.226.2</strong> — <?php _e('Test infra (#667 slice 3 — patch): TC_Admin singleton test-friendly visibility. Changed TC_Admin::$instance from private to public static (no production caller reads it externally, verified via grep) so tests can reset the singleton between it() blocks via direct assignment, matching the visibility of the test stub class. Added #[\\AllowDynamicProperties] for PHP 8.2+ forward-compat so $admin->tables = [...] fixture seeding does not trigger the deprecation-promoted-to-fatal under PHPUnit. Unblocks 4 previously-fataling tests for future per-test triage (deeper WP DB mocking mismatches now visible). Strict TDD: 4 ACs / 8 assertions, 2 RED -> 8 GREEN. PHP suite 522 files.', 'tc-data-tables'); ?></li>
                    <li><strong>4.226.1</strong> — <?php _e('Test infra (#667 slice 2 — patch per new versioning rule): PHPUnit bootstrap escape-function stubs. tests/bootstrap.php now defines htmlspecialchars-backed esc_html / esc_attr / esc_url stubs BEFORE WP_Mock::bootstrap(). WP_Mock\'s function_exists guard then skips its no-op pass-through versions, and the TC_*_Field_Renderer XSS guards see a real escape function under both legacy and PHPUnit runners. Shim failures: 29 -> 23 (6 renderer tests cleared: #795 list, #796 address, #798 checkbox, #799 signature, #808 post-image, #820 consent). Strict TDD: 5 ACs / 11 assertions, 9 RED -> 11 GREEN. PHP suite 521 files.', 'tc-data-tables'); ?></li>
                    <li><strong>4.226.0</strong> — <?php _e('Test infra (#667 slice): PHPUnit shim error-handler hardening. PHPUnitShimTest now installs a targeted set_error_handler around each require that silences ONLY E_WARNING/E_NOTICE "Undefined variable" messages; everything else still fatals through PHPUnit\'s strict conversion. This matches the legacy runner\'s tolerance for double-quoted regex literals like "/\\$wrapper.find(...)/", which legitimately contain $-prefixed text. Failures cleared on test-issue-836 and 2 others. Shim went from 32 failures to 29. Strict TDD: 4 ACs / 5 assertions, RED -> GREEN. PHP suite 520 files; vitest 957/957.', 'tc-data-tables'); ?></li>
                    <li><strong>4.225.0</strong> — <?php _e('Security (#1058): vitest 1.x to 4.x semver-major upgrade. Clears the 5 moderate dev-only CVEs #591 tracked but did not pin. vitest ^1.6.0 -> ^4.1.6, @vitest/coverage-v8 ^1.6.0 -> ^4.1.6, jsdom ^24.0.0 -> ^29.0.0. Full npm audit (prod + dev) now reports 0 vulnerabilities at any severity. vitest.config.js adds restoreMocks/clearMocks/mockReset so vi.spyOn no longer stacks across tests (4.x behavior change). All 957 vitest tests still green. Strict TDD: 2 ACs / 12 assertions, RED -> GREEN verified. PHP suite 519 files.', 'tc-data-tables'); ?></li>
                    <li><strong>4.224.0</strong> — <?php _e('Security (#591): CVE audit baseline ratchet. tests/test-issue-591-cve-baseline.php runs npm audit + composer audit on every test run and asserts 0 vulnerabilities on the production-shipped surface. Current state: npm prod 0, npm dev 5 moderate (vitest toolchain, tracked in #1058), composer prod + dev 0. Failure now surfaces a new CVE loudly at release-build time. PHP suite 518 files.', 'tc-data-tables'); ?></li>
                    <li><strong>4.223.0</strong> — <?php _e('Feature (#613 phase 2): Push Audit Log + Rate-limit status admin page. New submenu under Advanced Tables -> Push Audit Log. Surfaces per-source rate-limit caps + recent 100 push events (timestamp/source/table_id/row_id/success/error_code/HTTP). Read-only, manage_options gated, ABSPATH-guarded. Operators can now debug push failures from the WP admin without wp-cli. 9 ACs / 12 assertions. PHP suite 517 files.', 'tc-data-tables'); ?></li>
                    <li><strong>4.222.0</strong> — <?php _e('Refactor (#1049 Option 2 slice 4 — CLOSES major Option 2 refactors): renderEntries hot-loop. Three wins per render: $.each -> native for, html += concat -> array.push + join, $tbody.html -> direct innerHTML. Fires on every load/sort/filter/page-change. 4 new e2e vitest tests as equivalence proof; #832 contract test updated to accept new pattern. Vitest 953 -> 957. All four worst-offender Option 2 candidates now shipped: applyConditionalFormatting, applyStoredColumnOrderToBody, refreshActive, renderEntries.', 'tc-data-tables'); ?></li>
                    <li><strong>4.221.0</strong> — <?php _e('Refactor (#1049 Option 2 slice 3 of N): refreshActive typeahead highlight to vanilla DOM. Eliminates jQuery selector parse + wrap + .css() per item on EVERY arrow-key press in a typeahead. $list.children("li").each + $(this).css() -> native children NodeList for-loop + el.style.background. Existing typeahead tests cover the observable highlight color (rgb(240,246,252)); 17/17 still PASS as equivalence proof. No new tests needed.', 'tc-data-tables'); ?></li>
                    <li><strong>4.220.0</strong> — <?php _e('Refactor (#1049 Option 2 slice 2 of N): applyStoredColumnOrderToBody hot-loop. Two nested $.each replaced with native for-loops + querySelectorAll. $(this).attr -> el.getAttribute. $beforeActions.before(cell) -> beforeActionsEl.parentNode.insertBefore. Behavior pinned by 4 new e2e vitest tests: all 4 PASS pre-refactor (baseline lock) and still PASS after (equivalence). Live browser smoke confirms refactored method loaded. Vitest 949 -> 953.', 'tc-data-tables'); ?></li>
                    <li><strong>4.219.0</strong> — <?php _e('Refactor (#1049 Option 2 slice 1 of N): applyConditionalFormatting hot-loop perf. Cached the $wrapper.find("tbody tr") query outside the outer loop (was re-queried per configured column). Replaced $.each with native for-loops. Replaced .find("td").eq(columnIndex) with row.children[columnIndex] (direct DOM index access). Behavior pinned by 5 new e2e vitest assertions: all 5 PASS against pre-refactor impl, all 5 still PASS after. Live browser smoke confirms refactored method is loaded and table renders cleanly. Vitest 949 -> 954.', 'tc-data-tables'); ?></li>
                    <li><strong>4.218.0</strong> — <?php _e('Feature (#1049 Option 1B, slice 2 of 2): Frontend bundle runtime swap behind feature flag (gt_settings.use_frontend_bundle). When ON: HTTP requests for frontend modules drop from 55 to 1 (+ 3 unrelated top-level scripts). When OFF (default): existing 56-handle chain loads (backward compat). Verified live via direct mysql toggle + Playwright reload: ON loads 4 total GT scripts, OFF loads 59 total — both produce 0 console errors with full GravityTable.prototype.init present. Strict TDD: 8 ACs / 12 assertions. PHP suite 516 files.', 'tc-data-tables'); ?></li>
                    <li><strong>4.217.0</strong> — <?php _e('Feature (#1049 Option 1B, slice 1 of 2): Frontend script bundle BUILD artifact. New tools/build-frontend-bundle.sh concatenates the 56 frontend modules into a single assets/js/frontend-bundle.js (378 KB, 8813 lines) in canonical load order. Wired into tools/build-release.sh; runs with node --check syntax validation. Runtime behavior unchanged — slice 2 will flip the feature flag. Strict TDD: 7 ACs / 16 assertions including negative assertion that runtime is NOT yet swapped. PHP suite 515 files.', 'tc-data-tables'); ?></li>
                    <li><strong>4.216.0</strong> — <?php _e('Test infra (JS refactor Option 5 close-out): Vitest coverage backfill for the 2 uncovered frontend modules — alignment-resolver (10 tests, 3-layer precedence + middle no-class rule) and row-link-resolver (12 tests, template tokens + URL encoding + entry_id fallback). Vitest 927 -> 949 (+22). Unblocks Option 1B (script bundling).', 'tc-data-tables'); ?></li>
                    <li><strong>4.215.0</strong> — <?php _e('Feature (#613 phase 2 slice 20): TC_Notion_Push_Engine wire-up to payload normalizer. push_row signature extended with optional type_map 4th param. When type_map non-empty, payload routed through TC_Notion_Payload_Normalizer::denormalize_payload to produce typed Notion shapes. Backward compat preserved: empty type_map passes payload through unchanged. Schema-fetch step deferred to next slice. Strict TDD: 3 ACs / 2 new assertions. PHP suite 514 files.', 'tc-data-tables'); ?></li>
                    <li><strong>4.214.0</strong> — <?php _e('Feature (#613 phase 2 slice 19): Server-side push_baselines injection in get_entries. TC_Ajax::get_entries now resolves data_source_type, calls TC_Push_Conflict_Detector::load_baseline per row, packs non-empty results into response.data.push_baselines. JS half from v4.213.0 copies them into self._pushBaselines. End-to-end optimistic-locking flow is now LIVE: load -> baseline populated -> concurrent push elsewhere -> client push -> WP_Error(conflict) with tooltip. Strict TDD: 5 ACs / 5 assertions. PHP suite 514 files.', 'tc-data-tables'); ?></li>
                    <li><strong>4.213.0</strong> — <?php _e('Feature (#613 phase 2 slice 18): JS-side initial baseline population. renderEntries copies data.push_baselines into self._pushBaselines when the server provides them. Wire ready for end-to-end optimistic-locking; server-side push_baselines injection deferred to next slice. Strict TDD: 2 new vitest assertions. Vitest 927/62. Subtle debug note: Proxy ctx stub interferes with OR-fallback init; switched these tests to plain-object stub.', 'tc-data-tables'); ?></li>
                    <li><strong>4.212.0</strong> — <?php _e('Feature (#613 phase 2 slice 17): Auto-retry on rate_limited push responses. pushRowToSource retries once with a 1100ms delay (just past the 1s rate window) when the server returns rate_limited. Bounded to one retry to avoid infinite loops. Other error codes (conflict, push_failed, etc.) do NOT trigger retry. Strict TDD: 3 new vitest assertions; subtle fix needed for test-stub compat (use prototype.call instead of self.method). Vitest 925/62.', 'tc-data-tables'); ?></li>
                    <li><strong>4.211.0</strong> — <?php _e('Feature (#613 phase 2 slice 16): JS-side baseline maintenance. pushRowToSource now sends baseline_lastmod when self._pushBaselines[rowId] is set, and bumps it on successful response. Server returns new_baseline in success envelope. Activates the optimistic-concurrency conflict check user-visibly. Backward compat preserved: callers without _pushBaselines see no behavior change. Strict TDD: 3 new vitest assertions. Vitest 922 / 62 files. Initial-baseline population deferred to next slice.', 'tc-data-tables'); ?></li>
                    <li><strong>4.210.0</strong> — <?php _e('Feature (#613 phase 2 slice 15): Conflict-detector wire-up in push_row. Builds on v4.208.0 (the scaffold). Client (JS or custom code) may pass $_POST["baseline_lastmod"] reflecting what it last saw; server compares against stored baseline; mismatch -> WP_Error("conflict") that surfaces via the per-row gt-push-failed tooltip from v4.202.0. On successful push -> snapshot_baseline(source, row_id, time()) bumps the token. Optimistic-concurrency: avoids per-push HEAD/GET to remote. Strict TDD: 4 ACs / 5 assertions. JS-side baseline maintenance deferred to next slice (current bulk-action JS does not send baseline_lastmod yet, so the check is a no-op — backward compat preserved). PHP suite 513 files.', 'tc-data-tables'); ?></li>
                    <li><strong>4.209.0</strong> — <?php _e('Feature (#613 phase 2 slice 14): TC_Notion_Payload_Normalizer. Maps flat string values to Notion typed property shapes (title/rich_text/number/checkbox/select/status/multi_select/date/url/email/phone_number + defensive rich_text fallback). Counterpart to TC_Notion_Sync_Engine::normalize_property. Skips fields without a declared type rather than guessing. Strict TDD: 14 ACs / 24 assertions. Wire-up to TC_Notion_Push_Engine deferred. PHP suite 512 files.', 'tc-data-tables'); ?></li>
                    <li><strong>4.208.0</strong> — <?php _e('Feature (#613 phase 2 slice 13): TC_Push_Conflict_Detector scaffold. Source-agnostic counterpart to TC_Airtable_Conflict_Detector from #517 — stores per-{source, row_id} baseline lastmod timestamps. API: parse_lastmod / detect_conflict / baseline_storage_key / snapshot_baseline / load_baseline. Defensive: returns false (no conflict) on empty/garbage inputs rather than blocking pushes on bad data. Strict TDD: 10 ACs / 13 assertions. Wire-up to push_row deferred to next slice. PHP suite 511 files.', 'tc-data-tables'); ?></li>
                    <li><strong>4.207.0</strong> — <?php _e('Feature (#613 phase 2 slice 12): push_rows_to_source capability. Dedicated capability for the bulk-push flow added to TC_Capabilities_Service::CAPABILITIES; auto-granted to administrators on activation. TC_Ajax::push_row gate is now a three-tier check: push_rows_to_source -> gravityforms_edit_entries (legacy fallback) -> manage_options (admin override). 3 ACs / 6 assertions. AC#2 in the #554 capability-upgrade test was loosened from hard-coded ===4 to ===count(CAPABILITIES) so future cap additions don\'t regress it. PHP suite 510 files.', 'tc-data-tables'); ?></li>
                    <li><strong>4.206.0</strong> — <?php _e('Feature (#613 phase 2 slice 11): Per-source push rate limiter. TC_Push_Rate_Limiter caps pushes at JSON 10/sec, Airtable 5/sec, Notion 3/sec (matching each API\'s documented limit). TC_Ajax::push_row gates on should_throttle() before the engine and calls record_request() after; throttled requests return a typed "rate_limited" WP_Error code that surfaces via the per-row gt-push-failed tooltip from v4.202.0. 12 ACs / 15 assertions across 2 test files; all RED then GREEN. PHP suite 509 files.', 'tc-data-tables'); ?></li>
                    <li><strong>4.205.0</strong> — <?php _e('Feature (#613 phase 2 slice 10): Push audit log. New TC_Push_Audit_Log_Service (generic across all 3 engines) modeled on the existing Airtable audit log. append/load/clear API with timestamp auto-population, most-recent-first prepend, 500-entry bound. TC_Ajax::push_row wires it in: every push attempt logs source / table_id / row_id / success + error_code/message/http_code on failure. Unblocks operator debugging. 13 ACs / 19 assertions across 2 test files, all RED then GREEN. PHP suite 507 files.', 'tc-data-tables'); ?></li>
                    <li><strong>4.204.0</strong> — <?php _e('Feature (#613 phase 2 slice 9 — ALL 3 SOURCES COVERED): TC_Notion_Push_Engine. PATCH /v1/pages/{page_id} with Authorization: Bearer + Notion-Version header. Payload wrapped as {"properties": ...} per Notion API. Typed WP_Error guards: not_a_notion_source / pull_only / invalid_payload / missing_token / push_failed. AJAX dispatcher routes all three sources (json / airtable / notion) to the right engine. Caveat: caller must provide typed Notion property objects; payload normalizer is a future slice. Strict TDD: 8 ACs / 14 assertions, all RED before, all GREEN after. PHP suite 505 files.', 'tc-data-tables'); ?></li>
                    <li><strong>4.203.0</strong> — <?php _e('Feature (#613 phase 2 slice 8): TC_Airtable_Push_Engine + AJAX dispatcher. Mirrors the TC_JSON_Push_Engine surface from v4.196.0 — same typed WP_Error guards (not_an_airtable_source, pull_only, invalid_payload, missing_credentials, push_failed). Delegates HTTP PATCH to the existing TC_Airtable_Sync_Engine::update_record from #517. AJAX endpoint dispatches by data_source_type. 8 ACs / 13 assertions; all RED, then GREEN. PHP suite 504 files.', 'tc-data-tables'); ?></li>
                    <li><strong>4.202.0</strong> — <?php _e('Feature (#613 phase 2 slice 7): Per-row push outcome visual feedback. Successful rows get .gt-push-success (pale green tint); failed rows get .gt-push-failed (pale red + cursor:help) + title="<code>: <message>" attribute so hover surfaces the typed WP_Error code. Aggregate alert from v4.201.0 retained. Strict TDD: 3 JS behavioral assertions + 4 CSS contract assertions. Vitest 919/62, PHP 503.', 'tc-data-tables'); ?></li>
                    <li><strong>4.201.0</strong> — <?php _e('Feature (#613 phase 2 slice 6): Aggregate feedback after push_to_source completes. Tracks completed/succeeded/failed counters as each pushRowToSource callback fires; on the last callback, alerts "Pushed N row(s) successfully" or "Pushed N row(s), M failed". Pluralization handled. Strict TDD: 3 new behavioral assertions, all RED before impl, all GREEN after. Vitest 916 tests / 62 files. Per-row error code detail deferred to next slice.', 'tc-data-tables'); ?></li>
                    <li><strong>4.200.0</strong> — <?php _e('Feature (#613 phase 2 fifth slice — CLOSES THE STACK): push_to_source bulk-action JS intercept. performBulkAction now intercepts action="push_to_source" and delegates to pushRowToSource per selected entry, with payload extracted from each row\'s td[data-field-id] cells. The full end-to-end push path is now live: select rows -> pick "Push to source" -> Apply -> per-row HTTP PUT to ${json_url}/${row_id}. 4 new behavioral assertions; all RED before impl, all GREEN after. Vitest 913 tests / 62 files. Per-row feedback + Airtable/Notion engines deferred to next slices.', 'tc-data-tables'); ?></li>
                    <li><strong>4.199.0</strong> — <?php _e('Feature (#613 phase 2 fourth slice): "Push to source" bulk-action option. New <option value="push_to_source"> rendered conditionally in the bulk-action select when data_source_type ∈ {json, airtable, notion} AND sync_direction allows pushing. Label is translatable. Strict TDD: 5 ACs / 7 assertions, all RED before template edit, all GREEN after. Client-side intercept (loop pushRowToSource per selected entry) deferred to next slice. PHP suite 502 files.', 'tc-data-tables'); ?></li>
                    <li><strong>4.198.0</strong> — <?php _e('Feature (#613 phase 2 third slice): pushRowToSource prototype method. New assets/js/frontend/push-to-source.js (~60 lines) adds GravityTable.prototype.pushRowToSource(rowId, payload, onResponse?). Calls the gt_push_row endpoint from v4.197.0. Defensive guards for empty rowId/payload avoid round-tripping invalid input. Wired into the script dependency chain. Strict TDD: 7 ACs (all RED then GREEN). Vitest 909 tests / 62 files. UI trigger deferred to next slice.', 'tc-data-tables'); ?></li>
                    <li><strong>4.197.0</strong> — <?php _e('Feature (#613 phase 2 second slice): gt_push_row AJAX endpoint. Wires TC_JSON_Push_Engine (from v4.196.0) to a public AJAX action so frontend JS (next slice) can call it from inline-edit save. CSRF nonce check + current_user_can permission gate + shallow payload sanitization. Returns typed error codes (pull_only, missing_config, etc.) via wp_send_json_error. Strict TDD: 7 ACs / 13 assertions, all RED before the impl, all GREEN after. PHP suite 501 files.', 'tc-data-tables'); ?></li>
                    <li><strong>4.196.0</strong> — <?php _e('Feature (#613 phase 2 first slice): TC_JSON_Push_Engine scaffold. push_row(table_id, row_id, payload) HTTP-PUTs row updates back to the JSON source URL. Typed WP_Error guards for not_a_json_source / pull_only / missing_config / invalid_payload / unsafe_url / push_failed. RESTful URL: ${json_url}/${row_id} with slash normalization. Strict TDD: 10 declared ACs / 16 assertions; all RED before the engine file existed, all GREEN after. PHP suite crossed 500 files. Shortcode-side AJAX + frontend JS wiring deferred to next slice.', 'tc-data-tables'); ?></li>
                    <li><strong>4.195.0</strong> — <?php _e('Fix (#1027): Editor preview no longer logs "(intermediate value).init is not a function". Root cause: typeof-undefined guard passed when GravityTable was a stub but prototype.init was not yet wired up by extension modules. Fix: 4 guard sites in templates/table.php upgraded to also check typeof GravityTable.prototype.init === "function". Defense-in-depth: 6 admin slice scripts now explicitly depend on gravity-tables-admin-core. 12-assertion contract pins the dependency arrays. Live browser smoke verified 0 console errors on editor page (was 1 TypeError per load).', 'tc-data-tables'); ?></li>
                    <li><strong>4.194.0</strong> — <?php _e('Docs (#1023): README.md + readme.txt architecture sections refreshed. README rewrites the frontend JS module list (~50 modules under assets/js/frontend/), updates service-class count (70+ → 100+), adds multi-source data architecture and test-infra subsections. readme.txt Description block adds Data Sources Beyond Gravity Forms + Quality & Testing sections. New contract test with 12 assertions pins the README state against current architecture. TDD-verified: 5 went RED before the refresh, all GREEN after. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.193.0</strong> — <?php _e('Fix (#1024): Print button no longer takes the table into indefinite loading. Two guards added: (1) skip the heavy 5000-row refetch when totalEntries are already visible in the DOM, (2) re-entrancy guard prevents stacked AJAX on double-click. 4 new behavioral assertions; 2 went RED before the impl landed. Vitest: 902 tests (up from 898).', 'tc-data-tables'); ?></li>
                    <li><strong>4.192.0</strong> — <?php _e('Test infra (#891 action item 2 / #386): Multi-table responsive behavioral vitest. 3 new tests prove resize handlers are uniquely namespaced per instance and each instance reads its own this.config.responsive_mode. TDD-verified by removing the wrapperId suffix from the resize namespace to confirm RED. Vitest: 898 tests (up from 895). No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.191.0</strong> — <?php _e('Test infra (#891 action item 2 / #387): Multi-table scoping behavioral vitest for selection.js. 3 new tests prove getSelectedEntryIds + performBulkAction + post-success uncheck are scoped to the target table even when other tables have checked rows on the same page. TDD-verified by breaking $wrapper scoping to confirm RED, then restoring. Vitest: 895 tests (up from 892). No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.190.0</strong> — <?php _e('Test infra (#891 action item 2): New tests/js/frontend-row-click-exclusions.test.js — behavioral vitest replacing the brittle PHP file-grep AC in test-issue-433-keyboard-input.php. Tests the actual user flow: clicking an active cell editor (input, textarea) does not select the parent row. TDD-validated by breaking the impl to confirm RED, then restoring. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.189.0</strong> — <?php _e('Refactor (#891 phase 6 FINAL / test infra): Closes #891 action item 3 in full. Migrated the last two bundle patterns — gt_js_bundle_323 (9-file) and #535 inline bundling. ZERO gt_js_bundle_* local helpers remain in the test suite. ZERO inline file_get_contents bundling. 2 new suite-wide invariant assertions pin the state. Final arc tally (phases 1-6, v4.184.0 - v4.189.0): 34+ helpers migrated, 30+ files touched, 145+ contract assertions added. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.188.0</strong> — <?php _e('Refactor (#891 phase 5 / test infra): Migrated 11 sub-helpers across 3 slice clusters — #501_* (4), #518_* (3), #567_* (4). All clusters bundle the same canonical post-render-gates.js / bind-entry-events.js / render-entries.js / entry-row.js modules. 11 duplicate local definitions deleted across 11 test files. 44 new contract assertions. Only #535 inline bundling remains for phase 6. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.187.0</strong> — <?php _e('Refactor (#891 phase 4 / test infra): Migrated 4 multi-file gt_js_bundle_* helpers (#77, #195, #238, #243) that concatenate a primary JS file plus appendix paths (table-utils.js, load-entries.js, init.js + toolbar-export.js + export.js). 4 duplicate local definitions deleted. 16 new contract assertions. 22 of ~32 helpers now migrated; ~10 remain (slice-cluster + inline variants). No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.186.0</strong> — <?php _e('Refactor (#891 phase 3 / test infra): Migrated 7 more gt_js_bundle_* helpers (#362, #365, #366, #375, #384, #549, #556). 7 duplicate local definitions deleted. 28 new contract assertions. 18 of ~32 helpers now migrated; ~14 remain for phase 4+. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.185.0</strong> — <?php _e('Refactor (#891 phase 2 / test infra): Migrated 7 more 2-arg gt_js_bundle_* helpers (#98, #99, #108, #109, #128, #132, #194) to the shared gt_test_bundle_js() helper. 7 duplicate local definitions deleted, ~38 call-sites rewritten. 28 new contract assertions. 11 of the original ~32 local helpers now migrated; ~21 remain for phase 3+. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.184.0</strong> — <?php _e('Refactor (#891 phase 1 / test infra): New tests/helpers/bundle.php with gt_test_bundle_js(array $paths): string. Concatenates JS files with newline separators, silently skips missing. Migrated 5 test files (#126, #156, #196, #386, #387) to the shared helper, removing duplicate gt_js_bundle_responsive_card + gt_js_bundle_selection definitions. ~30 more gt_js_bundle_* helpers migrate in follow-up PRs. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.183.0</strong> — <?php _e('Refactor (#1013 / phase 1.6 of #613): Unified the sync_direction picker across all 3 external sources (JSON + Airtable + Notion). Removed the duplicate legacy "Airtable sync direction" picker (which used pull_only/push_only naming) from the Selection & Editing group. The canonical picker added in #1010 now shows for all 3 sources via the isExternal toggle in bind-events.js. No data migration — push consumer accepts both legacy + canonical naming (#1011). Eliminates duplicate id="gt-sync-direction" HTML violation. 6 new + 2 updated PHP contract assertions. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.182.0</strong> — <?php _e('Refactor (#1011 / phase 1.5 of #613): Airtable push consumer in class-tc-ajax.php now accepts both legacy (pull_only / push_only / bidirectional) and canonical (pull / push / two_way) sync_direction naming. Maps legacy values to canonical before the early-return check; canonical values pass through unchanged. Unblocks unifying the two sync_direction pickers in the table builder. 5 PHP contract assertions. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.138.0</strong> — <?php _e('Refactor (#833 slice 13 / #927): Extract assets/js/frontend/date-inputs.js (~180 lines, 1 method — setupDateInputs). HTML5 date + display mirror + preset chips + range validation. 3 new vitest + 7 PHP contract assertions. frontend.js drops to 1,085 lines (-82.9% cumulative). No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.137.0</strong> — <?php _e('Refactor (#833 slice 12 / #925): Extract assets/js/frontend/scroll-indicators.js (~400 lines, 1 method — setupScrollIndicators, the horizontal-scroll affordance overlay). **Crossed -80% cumulative shrink from the 6,359-line frontend.js baseline.** 3 new vitest + 7 PHP contract assertions. 3 sibling tests rewired. frontend.js drops to 1,243 lines (-80.5%). Browser-verified. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.136.5</strong> — <?php _e('Fix (#922): Admin-side extension of the #914/#916/#918/#920 console.log cleanup series. Commented out 22 console.log("GT Admin DEBUG: ...") calls in admin/js/admin-vanilla.js that fired in the WP admin every time a config UI button was clicked. Surfaced #923 (pre-existing syntax error in the same file, unrelated to this slice). No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.136.4</strong> — <?php _e('Fix (#920): Final batch of the console.log cleanup series (#914, #916, #918, #920). Removed console.log("GT Frontend: Date selected") from frontend.js (every date-picker selection) and console.log("GT Mobile Debug: Field") from responsive-card-view.js (every card-mode field on every render). Console now clean of GT-frontend debug spam. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.136.3</strong> — <?php _e('Fix (#918): Sibling of #914 and #916. Seven console.log("GT Add New: ...") calls in the Thickbox Add Entry popup handler removed. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.136.2</strong> — <?php _e('Fix (#916): Sibling of #914. Removed two bare console.log calls from frontend.js loadEntries ("GT Frontend: Loading entries with filters:" + "GT Frontend: Full AJAX data:"). Both fired on every page load + filter/sort/pagination change. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.136.1</strong> — <?php _e('Fix (#914): Removed console.log spam from assets/js/frontend/lookup-dropdown.js. Customer console was getting "GT Lookup: Making AJAX request..." on every lookup-field edit. console.error in error paths retained. New PHP contract test guards against reintroduction. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.136.0</strong> — <?php _e('Refactor (#833 slice 11 / #912): Extract assets/js/frontend/bind-entry-events.js (~210 lines, 1 method). bindEntryEvents is the per-row event delegation (action buttons, row-link clicks, inline-edit cell clicks, detail-toggle clicks, file-upload triggers). 3 new vitest + 13 PHP contract assertions. 5 sibling tests rewired (#325, #433, #556, #833 slice 7, #837). frontend.js drops to 1,611 lines (-74.7% from 6,359 baseline). Browser-verified. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.135.0</strong> — <?php _e('Refactor (#833 slice 10 / #910): Extract assets/js/frontend/bind-events.js (~250 lines, 1 method). bindEvents is the ~220-line global event-wiring method (toolbar, filters, search, sort, pagination, delegated edit/detail-popup, drilldown, cascade-parent change, .gt-table namespace hygiene). 3 new vitest + 13 PHP contract assertions. 9 sibling tests rewired — biggest batch ever for one slice (#75, #568, #599, #834 slices 1/2/3, #835, #836, #839). **frontend.js dropped below 1,800 lines** — now under 30% of the original 6,359-line monolith. Cumulative -71.9%. JS test count: 851 → 854. Browser-verified. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.134.0</strong> — <?php _e('Refactor (#833 slice 9 / #908): Extract assets/js/frontend/edit-field.js (~320 lines, 1 method). editField is the ~280-line inline-editor dispatcher that gates on frontend-editing config, picks the editor by field-type, and delegates to editAjsToolkitLookupField / populateLookupDropdown or renders inline (text / textarea / select / checkbox / radio / date / time / datetime) with saveField. 7 new vitest + 17 PHP contract assertions. 5 sibling tests rewired (#241, #433, #833 slices 3/4/8). frontend.js drops to 2,008 lines (-68.4% from 6,359 baseline). JS test count: 844 → 851 (+7). Browser-verified. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.133.0</strong> — <?php _e('Refactor (#833 slice 8 / #906): Extract assets/js/frontend/lookup-dropdown.js (~320 lines including IIFE wrapper, 1 method). **Largest single-method extraction yet** — populateLookupDropdown is 275 lines on its own. Three-tier fallback: invalid-config → text editor; AJAX with 10-second timeout safety net → text editor; AJAX success → select.gt-lookup-select with reverse-label lookup; AJAX error / network-fail → 3-second fallback. 15 new vitest + 24 PHP contract assertions. frontend.js drops to 2,286 lines (-64.1% from 6,359 baseline). JS test count: 829 → 844 (+15). Browser-verified. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.132.0</strong> — <?php _e('Refactor (#833 slice 7 / #904): Extract assets/js/frontend/row-actions.js (~170 lines, 4 helpers — paired with actions-cell.js from #832 slice 7). Methods moved: loadWooCommerceProducts, createWooCommerceProduct (with confirm + edit-URL window.open), viewEntryHistory (renders change-log into the detail popup), triggerInlineEditForEntry (DOM scan + scroll + row select). 21 new vitest + 26 PHP contract assertions. frontend.js drops to 2,560 lines (-59.7% from 6,359 baseline). JS test count: 808 → 829 (+21). Browser-verified. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.131.0</strong> — <?php _e('Refactor (#833 slice 6 / #902): Extract assets/js/frontend/print.js (~165 lines, 3 helpers). initPrint (wire .gt-print-btn click to the #531 print-all-rows pipeline with 5000-row cap + afterprint pagination restore + slice 1.2 error-skip), preparePrintHeader (timestamp + filter summary in .gt-print-header), summarizeActiveFilters (pure render of filter state, all 5 filter types). 27 new vitest + 24 PHP contract assertions. 4 sibling tests rewired (substring-coincidence per #891). frontend.js drops to 2,693 lines (-57.6% from 6,359 baseline). JS test count: 781 → 808 (+27). Browser-verified. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.130.0</strong> — <?php _e('Refactor (#833 slice 5 / #900): Extract assets/js/frontend/presets.js (~280 lines, 8 helpers). The saved-filter preset subsystem moves out: initPresets, findPresetById, renderPresetOptions, loadPresets (AJAX gt_get_filter_presets), savePresetPrompt (window.prompt + AJAX gt_save_filter_preset), deletePreset, applyPresetById, applyPresetFilters (writes filter values by type into the input DOM: date_range, number_range, dropdown, lookup, checkboxes, text). 37 new vitest assertions + 35 PHP contract assertions. frontend.js drops to 2,828 lines (-55.5% from 6,359 baseline). JS test count: 744 → 781 (+37). Browser-verified at ajstrucking.local. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.129.2</strong> — <?php _e('Forward-compat fix (#896): Removed deprecated $.trim calls from frontend modules. jQuery 4 dropped $.trim; production WordPress still ships jQuery Migrate (so customers were unaffected today), but the deprecation is real and Migrate itself is slated for eventual removal. Two call sites rewired: frontend.js:495 (drilldown cell-text trim) and frontend/edit-ajs-lookup.js:97 (search-input debounce). Both now use String(value || ).trim() — native ES5, defensive against null/undefined. Removed the temporary $.trim polyfill from tests/js/frontend-edit-ajs-lookup.test.js (added in v4.129.0 as a stopgap during slice 4). New PHP contract test guards against reintroduction. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.129.1</strong> — <?php _e('Bug fix (#893): Defensive type-coercion fix in the saveField recalc-loop. The check `recalcFieldId === fieldId` was strict-equals, but object keys from $.each are always strings — so a future caller passing fieldId as a Number would have the just-rendered cell clobbered by the echoed recalc value. No production reproduction (every existing caller in frontend.js sources fieldId from $field.data / $field.attr which return strings). Surfaced as a vitest assertion during #889 / slice 2 and filed as #893 per the rule that future work surfaced during the split gets its own GH issue. Fix: String(recalcFieldId) === String(fieldId). New vitest assertion for the numeric-fieldId case alongside the existing string-fieldId case. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.129.0</strong> — <?php _e('Refactor (#833 slice 4 of N): Extract assets/js/frontend/edit-ajs-lookup.js (~145 lines). The AJS-Toolkit search-as-you-type inline lookup editor moves out. Dispatches across four lookup types (clients, pits, destinations, materials) based on lookup_fields[fieldId].table substring, debounces typing at 200ms, hits ajs_toolkit_search AJAX endpoint, saves on click + Enter via saveField, restores on Escape, saves-if-changed on blur. 18 new vitest assertions + 24 PHP contract assertions. One follow-up filed (#896): $.trim was removed in jQuery 4, polyfilled in this slice s test as a stopgap (production works via jQuery Migrate), modernization deferred. frontend.js drops to 3,053 lines (-52.0% from 6,359 baseline). JS test count: 725 → 743 (+18). Browser-verified at ajstrucking.local. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.128.0</strong> — <?php _e('Refactor (#833 slice 3 of N): Extract assets/js/frontend/edit-keyboard-nav.js (~80 lines). Keyboard navigation between inline-editable cells. Two helpers: findNextEditCell ({entryId, fieldId} descriptor for next/prev/down direction with same-row + cross-row wrap) and scheduleEditOnTarget (250ms save-settle delay before entering edit mode). 20 new vitest assertions covering every direction branch, row-wrap behavior, edge cases, scheduleEditOnTarget semantics. 22 PHP contract assertions. **Crosses the halfway mark of the #830 split: cumulative shrink from the 6,359-line baseline is now -50.2%.** frontend.js drops to 3,164 lines. JS test count: 705 → 725 (+20). Browser-verified at ajstrucking.local — keyboard-nav helpers attached, 17 rows, no console errors. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.127.0</strong> — <?php _e('Refactor (#833 slice 2 of N / #889 — second child PR under #833, which itself is a child of #830): Extract assets/js/frontend/edit-save.js (~160 lines). The inline cell-edit AJAX save method (saveField) moves out as a clean cohesive slice. Pairs with edit-history.js from slice 1 — saveField calls into pushHistoryEntry + getFieldLabel via the prototype. All behaviors preserved verbatim: history interop (skip on _suppressHistory, no-op on equal values, otherwise push labeled diff), optimistic Saving... UI, AJAX payload shape (gt_update_entry + nonce + entry_id + table_id + updates), #553 WAF envelope (base64 payload wrap when config.waf_safe_payload is on), success path (escaped displayValue render + original-padding restore + recalc fan-out with gt-recalculated highlight + conditional-formatting reapply + column-totals refresh), server-error + network-failure paths (alert + restore). 26 new vitest assertions + 24 PHP contract assertions. Two sibling tests rewired (#553 WAF envelope + #833 slice 1 caller-preservation). One latent type-coercion bug filed as #893 (deferred per the rule that future work during the split gets its own issue). frontend.js drops to 3,245 lines (-49.0% from 6,359 baseline). JS test count: 679 → 705 (+26). No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.126.1</strong> — <?php _e('Bug fix (#890): Customer-affecting "stale JS detected" console warning fired on every page load. The actual frontend.js was always current — the version check itself was the bug. tools/bump-version.sh had been silently failing to update three JS-side version literals since #831 (the fallback at frontend.js:10 in the post-#831 declaration shape, plus both literals in core.js:50 VERSION and core.js:55 TC_JS_VERSION). All three were stuck at "4.99.0" from #831 through v4.126.0. Fixed bump-version.sh sed patterns + added a PHP contract test (8 assertions) that reads TC_VERSION from tablecrafter.php and asserts every JS-side literal matches, guarding future regressions. Browser-verified: window.GTCore.VERSION now reads "4.126.1" and the warning is gone. No customer-facing behavior change beyond removing the false-positive console noise.', 'tc-data-tables'); ?></li>
                    <li><strong>4.126.0</strong> — <?php _e('Refactor (#833 slice 1 of N — first child PR under #833, which itself is a child of #830): Extract assets/js/frontend/edit-history.js (~200 lines). The undo/redo subsystem for inline cell edits moves out as a clean first slice of the #833 split. Nine helpers moved: initUndoRedo (toolbar buttons + Cmd/Ctrl+Z keybinds with the #535 isContentEditable guard), pushHistoryEntry (with _undoLimit eviction), updateUndoRedoButtons (button disabled-state sync), undoLastEdit + redoLastEdit (stack pop/push orchestration), replayHistoryEntry (re-issues saveField with _suppressHistory flag, skips stale rows per #535), getFieldLabel (three-tier toast-label fallback: column_config → th text → "Field N"), shortValue (40-char truncation, "(empty)" for null), showUndoToast (aria-live polite status toast). 37 new behavioral vitest assertions + 40 PHP contract assertions. Two latent test bugs surfaced and fixed: #433 keyboard-input test had been substring-coinciding with the unrelated initUndoRedo keydown guard, and #535 stale-refs test scanned only frontend.js for helper definitions. frontend.js drops to 3,372 lines (-47.0% from 6,359 baseline). JS test count: 642 → 679 (+37). Browser-verified at ajstrucking.local/loads-hauled: both undo/redo buttons render in .gt-table-controls, correctly disabled with empty stacks, no console errors. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.125.0</strong> — <?php _e('Refactor (#832 slice 20 of N — thirty-third child PR under #830): Extract assets/js/frontend/responsive-card-view.js (~620 lines). Largest single-slice cut yet — the entire responsive block (card-view renderer, mobile/tablet/flip/collapse/modal modes, resize throttling, breakpoint detection, namespaced resize handlers) moves into one cohesive module. 18 behavioral vitest assertions cover the public surface and the AC#8 tablet-visibility config-key contract (tablet_visible, not show_cards_on_tablet). 4 sibling PHP responsive tests updated to bundle responsive-card-view.js for substring scans. Latent test-#387 bug surfaced and fixed (was accidentally matching gt-entry-checkbox in the removed responsive block instead of performBulkAction in selection.js). Slice-N+1 retirement on #126 / #156 / #196 / #348 / #386 grep targets. frontend.js drops to 3,506 lines (-44.9% from 6,359 baseline). JS test count: 624 → 642. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.124.0</strong> — <?php _e('Refactor (#832 slice 19 of N — thirty-second child PR under #830): Extract assets/js/frontend/column-reorder-dnd.js (206 lines). Eight cohesive helpers for the column-reorder DnD UI. Pairs with column-order-persistence.js from slice 15. Includes pure fieldIdFromTh helper + jQuery DnD wiring + touch-skip on pointer:coarse + Reset Columns button. 21 behavioral vitest assertions. First slice fully browser-tested per the new directive — column reorder verified end-to-end at ajstrucking.local/loads-hauled. frontend.js drops to 4,125 lines (-35.1% from 6,359 baseline, third-largest single-slice cut). JS test count: 603 → 624. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.123.0</strong> — <?php _e('Critical fix: frontend extension modules (28 of them) were silently failing to attach their methods to GravityTable.prototype in production. WordPress enqueue-order loaded extension modules BEFORE frontend.js (which declared window.GravityTable), so every modules early-return guard bailed and methods never attached. Surfaced by browser testing (test harness order masked the bug). Fix: replaced early-return with stub-constructor pattern; frontend.js constructor preserves any pre-existing prototype. Browser-verified: prototype method count jumped from 64 to 146 after fix. Deploy urgently to v4.99+ customers — they have likely been silently broken since #841.', 'tc-data-tables'); ?></li>
                    <li><strong>4.122.0</strong> — <?php _e('Refactor (#832 slice 18 of N — thirty-first child PR under #830): Extract assets/js/frontend/post-render-gates.js (167 lines). Two cohesive post-render DOM gates: applyRowExpiry (#501 row-expiry with hide/strikethrough/sink-bottom + inverse mode + grace + ISO local-midnight semantics) and applyAutoMerge (#518 rowspan merge with empty-cell termination + gt-totals-row exclusion). 18 behavioral vitest assertions across both helpers using jsdom. Slice-N+1 retirement on 8 file-grep tests (#501 slice 1/2/2.1/2.2, #518 slice 1/2/2.1). frontend.js drops to 4,275 lines (second-largest single-slice cut, -32.8% from 6,359 baseline). JS test count: 585 → 603. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.121.0</strong> — <?php _e('Refactor (#832 slice 17 of N — thirtieth child PR under #830): Consolidate getVisibleTableData into toolbar-export.js. Its only callers (the 3 export consumers) already live there after #832 slice 13. CROSSED THE 30% MILESTONE — cumulative shrink from 6,359-line baseline now -30.1%. frontend.js drops to 4,444 lines (-21). 6 new vitest assertions on getVisibleTableData covering header path + edge cases (tbody :visible scrape skipped because jsdom does not compute layout). #832 slice 13 contract extended automatically via $helpers list update. JS test count: 579 → 585. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.120.0</strong> — <?php _e('Refactor (#832 slice 16 of N — twenty-ninth child PR under #830): Move parseDateInput from frontend.js to util.js, joining the existing date helper family (it is the natural inverse of parseInputDate — HTML5 YYYY-MM-DD to display format with d/j/m/n/Y/y token support). Dead code cleanup: removed orphaned getFileExtension method (its caller renderFileUploadCell moved to detail-popup.js in #837 with inline extension parsing). 8 new behavioral vitest assertions covering every branch including config fallback chain and regex-mismatch pass-through. #841 contract automatically verifies via $extracted list update. frontend.js drops to 4,465 lines (-29.8% from 6,359 baseline). JS test count: 571 → 579. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.119.0</strong> — <?php _e('Refactor (#832 slice 15 of N — twenty-eighth child PR under #830): Extract assets/js/frontend/column-order-persistence.js (60 lines). Four small cohesive helpers around localStorage persistence of column order: _columnOrderKey (3-tier fallback: table_id → config.id → wrapperId), readStoredColumnOrder (defaults to [] on any error), saveStoredColumnOrder (with .map(String) coercion), clearStoredColumnOrder. All try/catch wrapped. 23 behavioral vitest assertions including exhaustive key derivation + round-trip verification. No slice-N+1 retirements needed. frontend.js drops to 4,505 lines (-29.2% from 6,359 baseline). JS test count: 548 → 571. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.118.0</strong> — <?php _e('Refactor (#832 slice 14 of N — twenty-seventh child PR under #830): Extract assets/js/frontend/filter-state-persistence.js (97 lines). Four cohesive helpers around v4.9.12 localStorage filter-state persistence: _filterStateStorageKey (key derivation), persistFilterStateLocal (write), restoreFilterStateLocal (read + merge with URL-takes-precedence semantics + DOM sync), clearFilterStateLocal (remove). All storage operations try/catch wrapped. 22 behavioral vitest assertions across all 4 helpers including URL-precedence merge verification and defensive error handling. No slice-N+1 retirements needed. frontend.js drops to 4,523 lines (-28.9% from 6,359 baseline). JS test count: 526 → 548. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.117.0</strong> — <?php _e('Refactor (#832 slice 13 of N — twenty-sixth child PR under #830): Extract assets/js/frontend/toolbar-export.js (121 lines). Six cohesive helpers: toolbarBuildCSV (pure RFC 4180 stringifier), toolbarTriggerDownload (Blob + anchor + revoke), toolbarCopyToClipboard (navigator.clipboard with execCommand fallback), toolbarDownloadCSV/Excel (orchestrators, Excel adds BOM + .xls + vnd.ms-excel MIME), toolbarDownloadPDF (#531 print-button route + #521 window.print fallback). 21 behavioral vitest assertions including 12 exhaustive on CSV escaping edge cases. Slice-N+1 retirement on #212 + #243. frontend.js drops to 4,568 lines (-28.2% from 6,359 baseline). JS test count: 505 → 526. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.116.0</strong> — <?php _e('Refactor (#832 slice 12 of N — twenty-fifth child PR under #830): Extract assets/js/frontend/responsive-visibility.js (88 lines). Five small pure helpers consolidated into one module: isFieldVisibleInCards, isFieldVisibleOnMobile, isFieldVisibleOnTablet, getMobileLabel, isFieldVisibleOnCurrentDevice. All read this.config.responsive_settings keyed by fieldId with strict !== false checks (0 and "" do NOT count as hidden). 31 behavioral vitest assertions exhaustively covering every branch table. No slice-N+1 retirements needed. frontend.js drops to 4,642 lines (-27.0% from 6,359 baseline). JS test count: 474 → 505. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.115.0</strong> — <?php _e('Refactor (#832 slice 11 of N — twenty-fourth child PR under #830): Extract assets/js/frontend/entry-row.js (86 lines). Two helpers: renderEntryRowHtml(entry, ctx) builds the entire <tr> markup including the optional detail-row sibling, composing all 5 cell-level helpers via a pre-resolved ctx flags object (DOM probes happen once upstream so the helper stays jQuery-free); renderNoEntriesRowHtml is a pure empty-state fallback. renderEntries body collapsed from ~50 lines to ~17. 24 behavioral vitest assertions including full canonical cell order verification. Slice-N+1 retirement on 5 file-grep tests (#556 + #832 slices 3/7/9/10). frontend.js drops to 4,690 lines (-26.2% from 6,359 baseline). JS test count: 450 → 474. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.114.0</strong> — <?php _e('Refactor (#832 slice 10 of N — twenty-third child PR under #830): Extract assets/js/frontend/entry-cell.js (129 lines). LARGEST single-PR cut so far: frontend.js drops 77 lines as the inner per-column $.each body collapses to a 3-line dispatch. Two helpers: resolveCellEditability (pure 4-branch verdict helper) + renderEntryCellHtml (orchestrator composing every previously-extracted cell helper — toggle-cell, text-cell, link-anchor, alignment-resolver, util.formatDate, util.escapeHtml). 33 behavioral vitest assertions covering exhaustive editability branches + every orchestrator dispatch path including #132/#439 contracts. Slice-N+1 retirement on 7 file-grep tests (#325, #439, #832 slices 4/5/6/8, #841). frontend.js drops to 4,726 lines (-25.7% from 6,359 baseline). JS test count: 417 → 450. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.113.0</strong> — <?php _e('Refactor (#832 slice 9 of N — twenty-second child PR under #830): Extract assets/js/frontend/detail-row.js (77 lines) — two pure helpers for the #556 expandable detail-rows feature. renderDetailToggleCellHtml builds the chevron toggle TD with full aria contract (aria-expanded + aria-controls + aria-label + data-entry-id + chevron glyph); renderDetailRowHtml builds the hidden gt-detail-row sibling tr with dl/dt/dd grid and gt-detail-row-empty fallback. 24 behavioral vitest assertions. Slice-N+1 retirement on #556 slice 3. frontend.js drops to 4,803 lines (biggest single-PR cut since the split started, -24.5% from 6,359 baseline). JS test count: 395 → 417. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.112.0</strong> — <?php _e('Refactor (#832 slice 8 of N — twenty-first child PR under #830): Extract assets/js/frontend/text-cell.js (71 lines). New pure-ish helper renderTextCellHtml(opts) factors out the default text-cell branch from renderEntries with all four sub-branches (fileupload, editable+value, editable+empty for CSS :empty, readonly + entry_id wrapper per #132, readonly + other). 12 behavioral vitest assertions covering every branch + XSS escape + data attributes + valignClass/colAlignStyle pass-through. Slice-N+1 retirement on #132. frontend.js drops to 4,835 lines (-23.9% from 6,359 baseline). JS test count: 383 → 395. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.111.0</strong> — <?php _e('Refactor (#832 slice 7 of N — twentieth child PR under #830): Extract assets/js/frontend/actions-cell.js (53 lines). New pure helper renderActionsCellHtml(entryId, config) factors out the per-row actions cell with 4 icons (👁 view, 🗑 delete, 🕘 history, 🛒 WC create). Conditional visibility for delete + WC branches preserved. Order preserved per v4.7.61. 13 behavioral vitest assertions. frontend.js drops to 4,848 lines. JS test count: 370 → 383. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.110.0</strong> — <?php _e('Refactor (#832 slice 6 of N — nineteenth child PR under #830): Extract assets/js/frontend/toggle-cell.js (65 lines). New pure helper renderToggleCellHtml(opts) factors out the #325 toggle-column branch from renderEntries. Builds td + nested switch span for boolean columns with editable vs readonly modes, custom Yes/No labels, all data attributes. 17 behavioral vitest assertions exercise every config combination. Slice-N+1 retirement on #325. frontend.js drops to 4,860 lines. JS test count: 353 → 370. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.109.0</strong> — <?php _e('Refactor (#832 slice 5 of N — eighteenth child PR under #830): Extract assets/js/frontend/link-anchor.js (59 lines). New pure helper buildLinkAnchorHtml(linkSettings, displayValue) factors out the #109 hyperlink-settings anchor builder from renderEntries. new_tab maps to target=_blank + noopener noreferrer (#362). 14 behavioral vitest assertions including XSS protection (escapeHtml on both href and inner text). Slice-N+1 retirement on #109, #362, #384. frontend.js drops to 4,867 lines. JS test count: 339 → 353. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.108.0</strong> — <?php _e('Refactor (#832 slice 4 of N — seventeenth child PR under #830): Extract assets/js/frontend/alignment-resolver.js (61 lines). New pure helper resolveCellAlignment(config, fieldId, entryId) consolidates the three-layer #549 alignment cascade (column h-align + column v-align base + per-cell v-align override). Pure: no DOM, no jQuery. 11 behavioral vitest assertions exercise every layer combination + cross-entry/cross-field isolation. Slice-N+1 retirement on #108, #365, #549 slice 1. frontend.js drops to 4,877 lines. JS test count: 328 → 339. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.107.0</strong> — <?php _e('Refactor (#832 slice 3 of N — sixteenth child PR under #830): Extract assets/js/frontend/row-link-resolver.js (60 lines). New pure helper resolveRowLinkAttrs(template, entry) factored out from the inline block at the top of renderEntries — encapsulates the #567 row-link template substitution + aria-label markup. Pure: no DOM, no jQuery, exhaustively unit-testable. 14 behavioral vitest assertions. Slice-N+1 retirement on three #567 file-grep tests. First pure-helper pull from the big renderEntries — future slices can pull additional pure sub-pieces (cell-formatter dispatch, alignment-style builder) before tackling the orchestrator itself. frontend.js drops to 4,890 lines. JS test count: 314 → 328. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.106.0</strong> — <?php _e('Refactor (#832 slice 2 of N — fifteenth child PR under #830): Extract assets/js/frontend/ssp.js (104 lines). Server-side processing pipeline (loadEntriesServerSide + renderSSPEntries) moved out — speaks the DataTables draw/start/length protocol against gt_server_side_entries. 17 behavioral vitest assertions covering AJAX payload shape, draw counter, start/length math, filter merge, success/error/fail paths, render-no-rows fallback. Slice-N+1 retirement on #195. frontend.js drops to 4,914 lines. JS test count: 297 → 314. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.105.0</strong> — <?php _e('Refactor (#832 slice 1 of N — fourteenth child PR under #830): Extract assets/js/frontend/totals.js (134 lines). Column totals row renderer moved out — updateColumnTotals (DOM driver) + new computeColumnTotal pure helper consolidating all 6 TC_Formula_Service aggregation modes (SUM/AVG/MIN/MAX/COUNT/COUNT_DISTINCT) + legacy auto-Sum fallback. 27 behavioral vitest assertions — 15 for the pure helper, 11 DOM smoke tests, 1 module guard. frontend.js drops to 4,981 lines — first time below 5,000 since the split started. JS test count: 270 → 297. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.104.0</strong> — <?php _e('Refactor (CLOSES #834 — slice 5 of 5, thirteenth child PR under #830): Extract assets/js/frontend/typeahead.js (160 lines). initTextFilterTypeaheads moved out — typeahead suggestion dropdown for text filter inputs with debounced AJAX, focus refresh, blur-hide-delay window, keyboard nav. Defensive bugfix: replaced $.trim() (removed in jQuery 3.5+) with String().trim(). 17 behavioral vitest assertions. Closes #834 fully (5 slices total: search + filter-panel + sort + filter-apply + typeahead = ~661 lines extracted with 71 behavioral assertions). frontend.js drops to 5,071 lines. JS test count: 253 → 270. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.103.0</strong> — <?php _e('Refactor (#834 slice 4 of N — twelfth child PR under #830): Extract assets/js/frontend/filter-apply.js (197 lines). Both applyFilters and clearFilters moved out of frontend.js. 20 behavioral vitest assertions exercise every per-field-type branch (date single + range, number/range, dropdown single + multi + lookup, checkboxes, default text + lookup) plus clearFilters reset. Per-field try/catch error isolation preserved. frontend.js drops to 5,193 lines (-186, biggest single PR cut). JS test count: 233 → 253. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.102.0</strong> — <?php _e('Refactor (#834 slice 3 of N — eleventh child PR under #830): Extract assets/js/frontend/sort.js (158 lines). Sort handler + multi-column shift-click state machine moved out of bindEvents into computeNextSortState (pure state-machine helper) + bindSortEvents (DOM wire-up). 17 behavioral vitest assertions — 9 for the pure state machine covering every transition, 7 DOM smoke tests, 1 module guard. Multi-sort cap of 3, aria-sort accessibility, .gt-resizer skip, responsive-mode skip, and numbered badges (1/2/3 for stack > 1) all preserved. frontend.js drops to 5,379 lines. JS test count: 216 → 233. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.101.0</strong> — <?php _e('Refactor (#834 slice 2 of N — tenth child PR under #830): Extract assets/js/frontend/filter-panel.js (88 lines). Advanced filter panel (5 handlers — toggle/apply/clear/Enter/select-change) moved out of bindEvents into a new bindFilterPanelEvents method. Text-input change deliberately does NOT trigger applyFilters to avoid partial-typing noise; Enter or button click only. 9 behavioral vitest assertions. frontend.js drops to 5,472 lines. JS test count: 207 → 216. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.100.0</strong> — <?php _e('Refactor (#834 slice 1 of N — ninth child PR under #830): Extract assets/js/frontend/search.js (58 lines). Search controls (.gt-search-btn click + .gt-search-input keypress) moved out of bindEvents into a new bindSearchEvents method. Scope-honest: #834 was ~700 lines and the filter/sort/search handlers are deeply intertwined; better to ship cohesive sub-slices. Future slices will extract sort handlers, per-column filter inputs, applyFilters/clearFilters, initTextFilterTypeaheads. 8 behavioral vitest assertions cover button click, Enter keypress, non-Enter ignored, live input read. frontend.js drops to 5,524 lines. JS test count: 199 → 207. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.99.0</strong> — <?php _e('Refactor (CLOSES #831 — eighth child of #830): Extract assets/js/frontend/core.js (143 lines). Pure helpers (naturalSort, parseCurrency, currencySort, checkVersionMismatch + VERSION) moved to a new window.GTCore namespace. Scope-honest adjustment: the umbrella estimated ~400 lines including the GravityTable constructor and init() but those cannot be cleanly extracted without restructuring the IIFE container; only the 4 pure helpers factor cleanly. Constructor + init extraction explicitly deferred and documented in the module header. Legacy function names (gtNaturalSort, gtParseCurrency, gtCurrencySort, gtCheckVersionMismatch, TC_JS_VERSION) preserved inside core.js so pre-#831 file-grep contracts keep matching. 28 behavioral vitest assertions cover every helper exhaustively. Slice-N+1 retirement on #99, #194, #344, #541. frontend.js drops to 5,539 lines. JS test count: 171 → 199. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.98.0</strong> — <?php _e('Refactor (CLOSES #837 — seventh child of #830): Extract assets/js/frontend/detail-popup.js (232 lines). Five methods moved: viewEntryDetails (AJAX + HTML build), showDetailsPopup (lazy overlay + close/Escape/Tab focus-trap/edit handlers), closeDetailsPopup (fadeout + focus restore), bindDetailViewEvents (new — wires view-detail click handler previously inlined in bindEntryEvents), renderFileUploadCell (single/multi-file URL rendering with image vs link decision based on extension). 30 behavioral vitest assertions including exhaustive renderFileUploadCell coverage (all 7 image extensions, XSS escape, JSON multi-file arrays, query strings) and full popup lifecycle (lazy creation, single-overlay reuse, aria-labelledby attach/remove, body class, Escape close, edit-button → triggerInlineEditForEntry). Slice-N+1 retirement on #98, #314, #375, #439. frontend.js drops to 5,622 lines. JS test count: 141 → 171. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.97.0</strong> — <?php _e('Refactor (CLOSES #839 — sixth child of #830): Extract assets/js/frontend/a11y-keyboard.js (128 lines). Row-link click + auxclick + keyboard activation handlers (#567), isResponsiveMode helper, and a new _navigate indirection method moved out. 20 behavioral vitest assertions cover every interaction path (click, modifier-keys, always-new-tab config, Enter/Space keyboard, auxclick middle/right buttons, defensive no-ops). Honest scope adjustment: umbrella estimated ~300 lines but feature-coupled keyboard handlers (date inputs, toggle switches, detail-row chevrons, undo/redo) stay with their features; cohesive a11y block that factors cleanly is row-link interaction. Slice-N+1 retirement on four #567 file-grep tests. frontend.js drops to 5,783 lines. JS test count: 121 → 141. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.96.0</strong> — <?php _e('Refactor (CLOSES #835 — fifth child of #830): Extract assets/js/frontend/pagination.js (128 lines). Four methods moved: computeTotalPages and formatPaginationInfoText (new pure helpers — exhaustively covered by behavioral tests), bindPaginationEvents (length-select + prev/next handlers — previously inlined in bindEvents), updatePagination (orchestrator delegating to the new helpers). 24 vitest behavioral assertions including the #185 large-table regression case (2,000+ rows correctly compute to 96 pages instead of being hidden). JS test harness extended to load all five split modules. Slice-N+1 retirement applied to four pagination file-grep tests (#128 / #185 / #344 / #435). frontend.js drops to 5,847 lines. JS test count: 97 → 121. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.95.0</strong> — <?php _e('Refactor (CLOSES #836 — fourth child of #830): Extract assets/js/frontend/selection.js. Three methods moved: bindSelectionEvents (toggle-all + bulk-action dispatcher wire-up — previously inlined in bindEvents), getSelectedEntryIds (new pure-DOM helper), performBulkAction (AJAX dispatcher with empty-selection guard + delete confirmation + #130 stale-selection cleanup). 11 behavioral vitest assertions exercise every method with controlled inputs and a stubbed $.post. Slice-N+1 retirement on #130 (bulk delete) so the file-grep contract reads both files concatenated. frontend.js drops to 5,915 lines. JS test count: 86 → 97. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.94.0</strong> — <?php _e('Refactor (CLOSES #838 — third child of #830): Extract assets/js/frontend/conditional-format.js. Full rule engine (applyConditionalFormatting + getColumnIndex + getCellValue + evaluateCondition + applyFormattingAction) moves out with the same Object.assign attachment pattern. 26 real behavioral vitest assertions cover the pure evaluateCondition rule evaluator (all 10 operators: eq/neq/lt/lteq/gt/gteq/contains/contains_not/empty/not_empty + numeric type coercion + unknown-operator defense) plus jsdom + real jQuery smoke tests for the DOM-touching methods (all 5 applyFormattingAction cases verified against real DOM mutations). frontend.js drops to 5,964 lines. JS test count: 60 → 86. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.93.0</strong> — <?php _e('Refactor (CLOSES #840 — second child of #830) + JS test-rigor patch: Extract assets/js/frontend/observers.js (initVisibilityObserver — IntersectionObserver / ResizeObserver / gt:redraw escape hatch). Public surface byte-identical via Object.assign attachment, just like #841. New gravity-tables-frontend-observers script handle wired through class-tc-shortcode.php. Real JS behavioral TDD adopted: tests/js/frontend-util.test.js (27 assertions, retroactive coverage for #841 helpers) and tests/js/frontend-observers.test.js (8 assertions covering IntersectionObserver / ResizeObserver branches + gt:redraw + capability guards). #672 slice 2b is no longer a prerequisite — vitest + jsdom were already wired; we just needed a stub-and-eval harness. Slice-N+1 retirement applied to #77 / #238 / #323 / #529 (file-grep tests now read frontend.js + observers.js); plus a brittle-regex fix in #535. frontend.js drops to 6,138 lines. JS test count: 25 → 60. No customer-facing behavior change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.92.0</strong> — <?php _e('Refactor (CLOSES #841 — first child of #830): Extract assets/js/frontend/util.js from the monolithic frontend.js (6,359 lines). Pure helpers moved out: escapeHtml, escapeRegex, normalizeToggleValue, formatDate, convertHtml5DateToFormat, parseInputDate, formatDateToTarget. Attached via Object.assign(window.GravityTable.prototype, ...) so every call site keeps working byte-identically. class-tc-shortcode.php enqueues the new gravity-tables-frontend-util handle before gravity-tables-frontend so the prototype methods exist when the constructor runs. frontend.js drops to 6,203 lines. 35 assertions covering module presence + Object.assign attachment + per-method definitions + enqueue order. Slice-N+1 retirement applied to two file-grep tests (#306 escapeRegex, #325 normalizeToggleValue) so both contracts now read frontend.js + util.js. No customer-facing behavior change — foundational work for the remaining children #831-#840.', 'tc-data-tables'); ?></li>
                    <li><strong>4.91.0</strong> — <?php _e('Feature (slice 3 of 3 — CLOSES #556): Expandable / collapsible detail rows runtime. Slice 1 (v4.7.49) shipped TC_Detail_Rows_Service. Slice 2 (v4.9.20) shipped the per-column admin toggle + column_detail_only persistence + template config injection — with the orphan invariant "no production caller in frontend.js yet" pinned by docs. Slice 3 (this release) wires the runtime: chevron toggle column emitted in thead + per parent row when at least one column is flagged detail-only; clicking the chevron expands a hidden gt-detail-row sibling rendered immediately after the parent, containing detail-flagged values as a responsive grid of label/value pairs. Keyboard accessible (native button semantics — Enter / Space activate), aria-expanded flips, aria-controls points at the row id, chevron rotates 90deg via CSS transition. Empty-detail rows still get a chevron with "No additional details for this row." in the panel so the UX stays consistent across rows. Zero changes to legacy tables — empty column_detail_only keeps the render identical to v4.90.0. 13 assertions. **Customer-facing**: tables with 10+ columns can hide secondary fields behind a row-click on mobile, preserving the parent layout and only revealing extras on demand. Closes #556 end-to-end.', 'tc-data-tables'); ?></li>
                    <li><strong>4.90.0</strong> — <?php _e('Feature (slice 3 of 3 — CLOSES #560): Streaming CSV export REST endpoint. Companion to the slice-2 JSON /rows endpoint. GET /wp-json/gt/v1/tables/{id}/rows.csv streams CSV via chunked GFAPI fetch (500 entries/page, 200-page defensive cap = 100k rows). Same permission gate + sort_col whitelist as JSON endpoint. The existing JS frontend AJAX path already paginates per-page so the perf-on-large-tables goal is already met today; the slice-2 + slice-3 REST endpoints provide the external-integration API surface. 20 assertions. **Customer-facing**: external integrations can pull full-table CSVs without timing out.', 'tc-data-tables'); ?></li>
                    <li><strong>4.89.0</strong> — <?php _e('Feature (slice 3 of 3 — CLOSES #562): Pivot view runtime render. Closes the pivot arc. TC_Ajax::get_entries replaces the entries set with TC_Pivot_Service::aggregate() output when pivot_config is enabled. Response carries is_pivot:true + pivot_config + aggregated rows with <col>_<op> keys per aggregate. All 5 operators (sum / count / avg / min / max) work. **Limitation**: slice-2 admin UI is single-aggregate; multi-aggregate is reachable via the gravity_tables_table_config filter or queued as a future repeater-UI enhancement. 12 assertions. **Customer-facing**: tables with the pivot toggle on + a valid config now actually render aggregated rows in the AJAX response instead of raw entries.', 'tc-data-tables'); ?></li>
                    <li><strong>4.88.0</strong> — <?php _e('Feature (slice 2 of 3 — #562): Pivot view admin opt-in. Slice 1 (v4.7.51) shipped TC_Pivot_Service. Slice 2 wires the admin: new "Pivot view (#562)" feature group with mode dropdown (raw / pivot), group-by column dropdown, aggregate column + operator dropdowns (single-aggregate UI; repeater is slice 3). admin.js posts the four flat keys; save_table composes into pivot_config object + delegates to slice-1 normalize. Slice 3 will wire the render path + multi-aggregate repeater. 34 assertions. **Customer-facing**: pivot config can now be set per-table in the builder — the runtime render still uses raw mode until slice 3 closes the arc.', 'tc-data-tables'); ?></li>
                    <li><strong>4.87.0</strong> — <?php _e('Feature (slice 2 of 3 — #560): Server-side pagination REST endpoint + admin opt-in. New TC_Pagination_REST class registers GET /wp-json/gt/v1/tables/{id}/rows returning the canonical {rows, total, page, page_size, total_pages} envelope. Query params parsed via slice-1 TC_Pagination_Service with bounds clamping; sort_col validated against the table\'s saved columns whitelist to prevent injection. Permission gate mirrors the AJAX checkTableAccessPermission pattern. New "Server-side pagination (#560)" feature group in the table builder with enable toggle + default_page_size (1..500) input. Slice 3 will bind DataTables serverSide: true to the endpoint + streaming CSV export. 43 assertions. **Customer-facing**: tables with 1000+ rows now have a real-perf option — external integrations can also query the new REST endpoint directly today.', 'tc-data-tables'); ?></li>
                    <li><strong>4.86.0</strong> — <?php _e('Safety (CLOSES #819 — last #793 follow-up child): GF fileupload multi-file edit clobber-guard. Previously inline-editing a multi-file fileupload column would overwrite the JSON array with the single scalar URL value, silently losing every URL except one. New TC_Fileupload_Edit_Guard detects this case; update_entry skips the write and surfaces the field id under blocked_fields in the AJAX response. Single-file edit + array-to-array replacement still work unchanged. Conservative detection rejects malformed JSON / empty arrays / mixed-type arrays. Full wp.media multi-file modal is a separate enhancement ticket. 17 assertions. **All 4 #793 follow-up children now closed (#817 name, #818 time, #819 fileupload, #820 consent).**', 'tc-data-tables'); ?></li>
                    <li><strong>4.85.0</strong> — <?php _e('Feature (CLOSES #820 — child of #793 follow-up): GF consent composite render. New TC_Consent_Field_Renderer composes "Accepted: \"Privacy policy\" (rev #3)" or "Not accepted" / empty. Filter gt_consent_field_format for terser variants. get_entries pre-composes bare slot; eye popup branches consent BEFORE generic scanner. Edit / paste explicitly N/A (consent is a legal artefact). 14 assertions. **Customer-facing**: consent columns finally show readable acceptance state instead of blank.', 'tc-data-tables'); ?></li>
                    <li><strong>4.84.0</strong> — <?php _e('Feature (CLOSES #818 — child of #793 follow-up): GF time composite render + numeric sort key. New TC_Time_Field_Renderer composes "9:30 am" (default 12h) or "21:30" (filter gt_time_field_format = "24h"). Handles 24h-only forms (no meridian) by auto-deriving am/pm. Edge cases: 12am midnight, 12pm noon. sort_key returns minutes-since-midnight; empty cells sentinel 1500. 19 assertions. **Customer-facing**: time columns finally show readable times + sort correctly by hour-minute.', 'tc-data-tables'); ?></li>
                    <li><strong>4.83.0</strong> — <?php _e('Feature (CLOSES #817 — child of #793 follow-up): GF name composite render. New TC_Name_Field_Renderer composes "Mr. John Smith Jr." via token format string ({prefix} / {first} / {middle} / {last} / {suffix}). Default format {first} {last} with prefix prepended + suffix appended when format omits them. Filter gt_name_field_format lets themes pick alternates (e.g. {last}, {first}). Defensive collapse: double-spaces, leading/trailing commas. get_entries pre-composes bare slot; eye popup uses dedicated branch for predictable ordering. 17 assertions. **Customer-facing**: name columns finally show readable composed names instead of blank cells.', 'tc-data-tables'); ?></li>
                    <li><strong>4.82.0</strong> — <?php _e('Audit close (CLOSES #793 — umbrella): GF field-type support audit complete. Filed 4 final children for residual render gaps (#817 name, #818 time, #819 fileupload, #820 consent) and marked the entire Add-on section in docs/developer/field-type-support.md as deferred-to-per-add-on-ticket (those can only be audited after the owning add-on is installed). Cumulative across all slices: 14 children filed (10 closed + 4 open for follow-up), 13 minor releases (v4.69.0-v4.81.0), ~1000 new test assertions. The matrix doc is the source-of-truth for what Gravity Tables supports per field type. **No runtime change in this release** — closes the umbrella tracking ticket.', 'tc-data-tables'); ?></li>
                    <li><strong>4.81.0</strong> — <?php _e('Feature (CLOSES #810 — child of #793 audit): GF post_content HTML cell strategy. Previously admins saw escaped HTML tags as literal text. New TC_Post_Content_Field_Renderer ships render_preview (strips HTML + truncates to 100 chars with ellipsis; filter gt_post_content_preview_length overrides; multibyte-safe), render_full_html (delegates to wp_kses_post — allows safe HTML, strips scripts), and searchable_text (no-truncation plain text for filter / sort). Cell renderer adds post_content AND post_excerpt branches. Eye popup uses render_full_html. 16 assertions. **All 5 slice-3 #793 children now closed (#806-#810). Customer-facing**: post_content / post_excerpt cells finally show readable previews; eye popup shows the rendered body.', 'tc-data-tables'); ?></li>
                    <li><strong>4.80.0</strong> — <?php _e('Feature (CLOSES #807 — child of #793 audit): GF product composite render. New TC_Product_Field_Renderer composes "Widget × 3 @ $10.00 = $30.00" from sub-inputs N.1 (name), N.2 (price), N.3 (quantity). Currency prefix preserved in line total. sort_key returns numeric line total; empty cells sort last in ASC. get_entries pre-composes into bare slot; eye popup uses same composer. 17 assertions. **Customer-facing**: product columns now show readable line summaries instead of blank cells, and sort by line total.', 'tc-data-tables'); ?></li>
                    <li><strong>4.79.0</strong> — <?php _e('Feature (CLOSES #808 — child of #793 audit): GF post_image composite render. New TC_Post_Image_Field_Renderer with sub_input_values / render_html / render_text. Cell emits <img class="gt-cell-post-image" src=N.1 alt=N.5 title=N.2> with 40px default max-height (filter gt_post_image_max_height overrides). get_entries pre-composes the HTML into the bare slot; eye popup uses labelled multi-line text. Edit is N/A per existing non-editable list. 19 assertions. **Customer-facing**: post_image columns now show the actual image instead of blank cells.', 'tc-data-tables'); ?></li>
                    <li><strong>4.78.0</strong> — <?php _e('Feature (CLOSES #809 — child of #793 audit): GF post_category strip-term-id render. Previously the cell showed Name:ID (e.g. "Uncategorized:1"). New TC_Post_Category_Field_Renderer::render_text strips trailing :N term-id from each comma-separated segment, preserving inner colons. Cell renderer wires the strip via a "post_category" branch. 13 assertions. **Customer-facing**: post_category cells finally show readable category names.', 'tc-data-tables'); ?></li>
                    <li><strong>4.77.0</strong> — <?php _e('Security feature (CLOSES #806 — HIGH PRIORITY child of #793 audit): GF creditcard field PCI-safe render. Previously the cell render passed $entry[N] (typically empty for composites) through esc_html, the eye popup\'s generic multi-input scanner concatenated every N.1-N.5 sub-input, and the Abilities API query_rows returned raw entries. New TC_Creditcard_Field_Renderer ships render_safe (emits only "Visa ending in 1234" from last 4 digits of N.1), render_safe_full (adds holder name for eye popup), redact_for_payload (scrubs N.1-N.5 from entry shape). Cell renderer / AJAX get_entries / eye popup / Abilities API query_rows all wired. Never reads N.2 / N.3 (full PAN / CVV slots — empty in GF 2.x but defended defensively). Sort / filter / paste / export remain ➖ N/A. 22 new assertions; output guaranteed to contain no digit run longer than 4 chars. **Customer-facing**: tables embedding payment forms can be safely admin-viewed or exposed to MCP / AI tooling without PCI leakage risk.', 'tc-data-tables'); ?></li>
                    <li><strong>4.76.0</strong> — <?php _e('Audit (slice 3 of N — #793): Filed 5 child issues for Post + Pricing field-type gaps. #806 creditcard HIGH PRIORITY (PCI — must not leak full PAN; covers abilities redaction), #807 product composite render, #808 post_image composite, #809 post_category term-name display, #810 post_content HTML cell strategy. Each child carries AC + TDD plan. Matrix cross-references updated. **No runtime change** — child issues drive the fixes in subsequent iters.', 'tc-data-tables'); ?></li>
                    <li><strong>4.75.0</strong> — <?php _e('Feature (CLOSES #797 — child of #793 audit): GF multiselect facet filter + sort key service. New TC_Multiselect_Filter_Service with split_values / distinct_values / row_matches / sort_key. Distinct-value facet matching (OR-match, case-insensitive). First-value sort key with empty-cells-last sentinel. The filter-UI repaint that surfaces distinct-value checkboxes is a downstream JS wiring step — out of scope for this PR. 16 new assertions. All 5 originally-filed #793 child issues are now closed (#795, #796, #797, #798, #799).', 'tc-data-tables'); ?></li>
                    <li><strong>4.74.0</strong> — <?php _e('Feature (CLOSES #798 — child of #793 audit): GF checkbox field renders selected options. Previously cells appeared blank because GF stores each option in a separate sub-input (N.1, N.2, ...) and the bare entry slot is empty. New TC_Checkbox_Field_Renderer collects every non-empty {N}.{integer} sub-input, sorts numerically (preserves form-builder choice order), joins comma-separated. get_entries post-processes the AJAX response; eye popup uses the same render. Defensive: malformed sub-keys (5.foo, 5.1.2) are rejected. 14 new assertions. **Customer-facing**: checkbox columns finally show selected options.', 'tc-data-tables'); ?></li>
                    <li><strong>4.73.0</strong> — <?php _e('Feature (CLOSES #796 — child of #793 audit): GF address field renders the full postal block. Previously only street (sub-input N.1) appeared in tables; city / state / zip / country were silently lost. New TC_Address_Field_Renderer composes the six sub-inputs into a postal-layout text block. get_entries post-processes the AJAX response to populate the bare entry slot so the JS renders the full address. Eye popup uses the same postal layout. 16 new assertions. **Customer-facing**: addresses finally display fully in tables and the eye popup — no more silent data loss.', 'tc-data-tables'); ?></li>
                    <li><strong>4.72.0</strong> — <?php _e('Feature (CLOSES #799 — child of #793 audit): GF signature field renders as inline <img>. Previously the URL rendered as plain text. New case "signature" branch in TC_Cell_Renderer plus render_signature() helper emits <img class="gt-cell-signature"> with default 40px max-height. Filter gt_signature_max_height overrides. Alt respects context[name] (defaults to "signature"). URL resolved via existing resolve_url helper for AJAX/REST contexts (#606). 9 new assertions. **Customer-facing**: signatures captured via the GF Signature add-on now appear inline in the table instead of as URL text.', 'tc-data-tables'); ?></li>
                    <li><strong>4.71.0</strong> — <?php _e('Feature (CLOSES #795 — child of #793 audit): GF list field renders properly. Previously list-field columns rendered as serialised PHP garbage (a:N:{...}) because GF stores list-row-data as serialize($rows) at the bare field-id slot. New TC_List_Field_Renderer (safe unserialize with allowed_classes=false) drives render (HTML table for multi-col, headerless for single-col), eye popup (row-per-line text), and abilities API (query_rows now returns deserialised JSON arrays for list-field values). All cell content escaped via esc_html. 42 new assertions. **Customer-facing**: list fields finally show readable rows in tables and the eye popup; AI tooling via the Abilities API receives structured JSON instead of serialised garbage. Filter / sort / inline edit / paste for list fields remain follow-up scope.', 'tc-data-tables'); ?></li>
                    <li><strong>4.70.0</strong> — <?php _e('Audit (slice 2 of N — #793): Filed 5 child issues for the worst Standard / Advanced field-type gaps surfaced by the slice-1 matrix. #795 list-as-serialised-PHP (high priority), #796 address renders only street (city/state/zip/country lost), #797 multiselect filter / sort, #798 checkbox composite render, #799 signature inline image. Each child carries AC + TDD slice plan. Matrix cross-references updated. **Customer-facing**: no runtime change yet — this slice queues the per-field fixes; subsequent iters drive them through PR + release per the established loop pattern.', 'tc-data-tables'); ?></li>
                    <li><strong>4.69.0</strong> — <?php _e('Docs / audit (slice 1 of N — #793): GF field-type support matrix scaffold. New docs/developer/field-type-support.md enumerates every Gravity Forms field type (~50 entries across Standard / Advanced / Post / Pricing / Add-on / Custom) × every Gravity Tables operation (Render / Filter / Sort / Inline edit / Eye popup / Clipboard paste / CSV-XLSX export / Abilities API / Conditional formatting). Each cell carries one of five verdicts (supported / partial / unsupported / needs-audit / N/A). Slice 1 fills the obvious cells from a renderer + filter source read; subsequent slices fill the rest via probe fixtures and file per-gap child issues. 71 new assertions. **Customer-facing**: no runtime change — this is the audit scaffold that drives per-field fix issues going forward.', 'tc-data-tables'); ?></li>
                    <li><strong>4.68.0</strong> — <?php _e('Feature (slice 3 of 3 — CLOSES #516): Header-matching column-type preservation for clipboard paste. When the pasted first row is headers AND every header matches one of the table\'s saved column labels (case-insensitive, whitespace-trimmed), the apply path rewires the column-id order to follow the pasted header order. Partial matches return null → falls back to positional. New static helper TC_Clipboard_Paste_Handler::rewire_columns_by_header. Stress invariant: 1000x20 paste parses + rewires under 5s. 12 new assertions. **Customer-facing**: reorder a column in Excel and the next paste still lands in the right Gravity Forms fields.', 'tc-data-tables'); ?></li>
                    <li><strong>4.67.0</strong> — <?php _e('Feature (slices 2 + 3 — CLOSES #526): Media-folder JS adapter + fallback-image picker + tooltip. Slice 1 (v4.7.41) shipped TC_Media_Folder_Adapter (PHP detection for FileBird / FolderPress / WP Media Folder / Real Media Library). This release ships assets/js/gt-media-folder.js exposing window.GTMediaFolder.openFrame, the enqueue + localize boot, a new "Default image fallback (#526)" group in the table builder with a URL input + "Pick image" button that uses the adapter, and a tooltip that names the active folder plugin (or lists all four supported ones when none is detected). Picked URL is sanitized via esc_url_raw and stored on the table config. **Customer-facing**: admins with FileBird / FolderPress / WP Media Folder / Real Media Library installed now get the folder browser inside Gravity Tables\' image picker. Without any of those plugins, the plain wp.media frame still opens — no regression. 29 new assertions.', 'tc-data-tables'); ?></li>
                    <li><strong>4.66.0</strong> — <?php _e('Feature (slice 2 of 3 — #516): Clipboard paste-from-Excel admin handler. New "Paste data (#516)" group in the table builder with a TSV textarea, headers toggle, append / replace radios, and Preview / Apply buttons. Two AJAX endpoints (gt_clipboard_paste_preview / gt_clipboard_paste_apply) gated on manage_options + nonce. Preview reports rows / cols / detected dialect via slice-1 TC_TSV_Parser_Service. Apply writes via GFAPI::add_entry per row; replace mode trashes existing active entries first. Slice 3 will add column-type preservation + #496 detection integration + stress invariant. **Customer-facing**: copy a range from Excel / Sheets / Numbers, paste into the table builder, click Preview to see the parsed shape, then Apply to ingest the rows.', 'tc-data-tables'); ?></li>
                    <li><strong>4.65.0</strong> — <?php _e('Feature (slice 3 of 3 — CLOSES #519): Scheduled export admin UI + email + Run-now + filter opt-in. New "Scheduled export (#519)" group in the table builder Display Options with 6 controls (enabled toggle, recurrence selector hourly / 6h / daily / weekly, format selector CSV / XLSX, filename pattern, email recipients, honor-filters toggle) plus a "Run export now" button. AJAX handler ajax_run_now under capability gate manage_options. Email destination via wp_mail attachment when recipients are set. Filter opt-in via new gt_scheduled_export_search_criteria filter hook. save_table reconciles the WP-Cron queue on save. **Customer-facing**: per-table scheduled CSV / XLSX export is now configurable end-to-end from the table builder UI; admins can email exports to a recipient list or write them under uploads/gravity-tables-exports/ on hourly / 6h / daily / weekly cadence.', 'tc-data-tables'); ?></li>
                    <li><strong>4.64.0</strong> — <?php _e('Feature (slice 2 of 3 — #519): Scheduled outbound export runner. Slice 1 (v4.7.37) shipped TC_Export_Filename_Service — pure token expander for filename patterns. Slice 2 (this release) ships the runner: new TC_Scheduled_Export_Service registers a WP-Cron hook (gt_run_scheduled_export), exposes schedule_for_table / clear_schedule_for_table / run_scheduled_export. The cron callback queries entries via GFAPI::get_entries, renders them to CSV (or XLSX when PhpOffice\\PhpSpreadsheet is loaded — falls back to CSV otherwise), and writes the file to wp-content/uploads/gravity-tables-exports/. Failure paths return distinct WP_Error codes (gt_export_not_found / gt_export_unavailable / gt_export_write_failed / gt_export_query_failed / gt_export_invalid_args) and log to error_log so admins find them in debug.log without needing the slice-3 admin notice. Mirrors the inbound counterpart TC_Auto_Import for parity — same 6h custom cron interval, same idempotent schedule_for_table pattern. Slice 3 (deferred): per-table admin settings panel, email destination + recipient list, "Run export now" button, "honors current filters" opt-in. CSV writer passes the PHP 8.5+ $escape arg explicitly to avoid the fputcsv deprecation warning. 56 new assertions in tests/test-issue-519-slice2-scheduled-export-service.php; 368/368 plugin tests pass. **Customer-facing**: not yet — the runner is shipped and bootstrapped, but until slice 3 wires the admin UI the only way to enable a scheduled export is calling TC_Scheduled_Export_Service::schedule_for_table($id, "daily") from a custom snippet.', 'tc-data-tables'); ?></li>
                    <li><strong>4.63.0</strong> — <?php _e('Feature (slice 3 of 3 — CLOSES #503): Abilities Registry write wire-up + premium gating + WP <7.0 admin notice + bootstrap. Closes the three-slice arc for #503. (1) Premium gate in dispatch() fires BEFORE the concrete handler so we never hit GF / DB on the free-tier denial path — free tier returns WP_Error gt_ability_premium_required (status 402) for write abilities; read abilities skip the gate. (2) gravity_tables.update_cell delegates to GFAPI::update_entry after get_entry resolves the row; gravity_tables.create_row delegates to GFAPI::add_entry with form_id resolved from the table binding; gravity_tables.delete_row soft-deletes via GFAPI::update_entry status=trash. (3) New boot() bootstrap hooks register() on init and arms the legacy-WP dismissible admin notice on admin_init. (4) require_once for the registry is back (had been intentionally dropped between slices to avoid loading dead code). (5) New docs/developer/abilities.md documents the dispatch API and the six canonical ability ids with sample invocations. **Customer-facing**: on WP 7.0 sites the platform-level Abilities API now sees Gravity Tables as a first-class participant — external AI tooling can list / read / query (free tier) and update / create / delete (premium) via the standard discovery layer. On WP <7.0 a one-line dismissible notice surfaces in admin so site owners know the integration is dormant. MCP adapter integration is deliberately not in scope — the WP 7.0 MCP spec is still moving; once it stabilizes, a follow-up issue can land transport-specific glue. 40 new assertions in tests/test-issue-503-slice3-writes-and-notice.php; 194 assertions across all three slice suites; 367/367 plugin tests pass.', 'tc-data-tables'); ?></li>
                    <li><strong>4.62.0</strong> — <?php _e('Feature (slice 2 of 3 — #503): Abilities Registry read-only wire-up. Slice 1 (v4.7.31) shipped TC_Abilities_Registry with six canonical ability definitions and stub callbacks returning WP_Error gt_ability_not_wired. Slice 2 wires the three read-only abilities to real handlers: gravity_tables.list_tables (delegates to TC_Admin::get_tables), gravity_tables.get_table_schema (delegates to TC_Admin::get_table + get_table_config), and gravity_tables.query_rows (delegates to GFAPI::get_entries with filter / sort / pagination). New public dispatch(string, array) API routes any ability invocation through validated argument handling; write abilities (update_cell / create_row / delete_row) still return the slice-1 not_wired stub until slice 3 lands premium gating + MCP adapter integration. 48 new assertions plus 106 surviving slice-1 assertions. **Customer-facing**: on WP 7.0 sites with the Abilities API loaded, external AI tooling and MCP clients can now read tables / schemas / rows via the platform-level discovery layer.', 'tc-data-tables'); ?></li>
                    <li><strong>4.61.0</strong> — <?php _e('Feature (slice 2 of 2 — CLOSES #501): Row Expiry admin opt-in. Slice 1 (v4.7.30) shipped TC_Row_Expiry_Service plus the frontend render gate (applyRowExpiry in assets/js/frontend.js) and the four expiry_* keys round-tripped through sanitizers, shortcode attrs, and the template — but the table-builder UI to set them per-table had been removed without restoration (history at #657). This release adds a new "Row Expiry (#501)" group in Display Options with four controls: an Expiry column dropdown (Date / DateTime / Time fields from the bound Gravity Form), a Behavior selector (Hide / Strikethrough / Move to bottom), a Grace period input (days), and an Inverse-mode checkbox ("show only upcoming"). Values pre-fill from $table_settings; admin.js posts the four keys at top-level of the save payload so they land in $_POST where the existing slice-1 sanitizers already accept them. Admin preview always shows every row regardless. 33 new assertions in tests/test-issue-501-slice2-row-expiry-admin.php. **Customer-facing**: per-table row expiry can now be configured directly in the builder UI — no more shortcode-only / filter-only access.', 'tc-data-tables'); ?></li>
                    <li><strong>4.60.4</strong> — <?php _e('Bugfix: the entry-detail "eye" popup was rendering "-" for every plain select / radio / checkbox field. Reported on a Load Tracker site where the popup showed the numeric amount but no unit ("Tons", "Yards") for the "Choose Measurement" column. Root cause: Gravity Forms 2.9.29 changed `GFCommon::get_lead_field_display()`\'s signature from `($field, $entry, $currency)` to `($field, $value, $entry, ...)`. Our call site in `TC_Ajax::get_entry_details()` still used the old shape; GF\'s compat wrapper salvaged the currency but fed the entry array into the `$value` slot, which made select-label resolution return nothing. Fixed the call site to pass `($field, $raw, $entry)` per the current signature. Pinned by tests/EntryDetailLeadFieldDisplayCallSignatureTest.php. **Customer-facing**: every plain select / radio / checkbox in the eye popup now displays its actual label instead of a dash.', 'tc-data-tables'); ?></li>
                    <li><strong>4.60.3</strong> — <?php _e('Bugfix: sticky header silently regressed on sites running aggressive critical-CSS pipelines (NitroPack / WP Rocket) combined with heavy themes (JupiterX + Elementor Canvas). The `sticky_header` toggle stayed checked but the thead no longer froze on scroll. Local reproduction passed cleanly; the failure was production-only. Defensive fix in assets/css/frontend.css: added `position: -webkit-sticky` (older iOS / Safari prefix) and promoted `position: sticky` to `!important` so critical-CSS minifiers cannot strip the declaration and late-loading theme styles cannot override it at equal specificity. The `!important` is on `position`, not `z-index`, so the #617 audit remains clean. Pinned by tests/StickyHeaderDefensivePositionTest.php. **Customer-facing**: the sticky header behaves again on sites where it had silently dropped out.', 'tc-data-tables'); ?></li>
                    <li><strong>4.60.2</strong> — <?php _e('Two-bug bundle reported by a customer on 2026-05-12. **(A)** The frontend "Add New Entry" popup was unclickable -- every click in the form area dismissed the modal, and on production the popup also appeared fully black. A v4.7.65 regression lowered the modal stacking context (#TB_window z-index) to 9999, below WordPress ThickBox\'s overlay at 100050; the dismiss-layer therefore sat in front of the form, swallowing every click. Fix: keep #TB_window at 9999 (honouring the #617 ceiling) and add a new #TB_overlay rule at 9000 in our stylesheet so the modal sits above the overlay. Pinned by tests/AddEntryPopupZIndexTest.php. **(B)** The "Add New Entry" button itself disappeared on tables that did not have Advanced Filters enabled, because its render block in templates/table.php was nested inside the `if ($show_advanced_filters)` conditional. Extracted the button into its own standalone conditional gated only by `$show_add_entry` + `$user_can_add`. Pinned by tests/AddEntryButtonNotNestedInFiltersTest.php. **Customer-facing**: the button now renders for every table that has it enabled, and clicks inside the popup land on the form fields rather than dismissing the modal.', 'tc-data-tables'); ?></li>
                    <li><strong>4.60.1</strong> — <?php _e('Feature: Modernized the Table Features admin interface. Finalized the structural reorganization of the "Advanced & Export" settings into logical, grid-compliant modules (Security & Advanced, Data & i18n, Print & Export) and refined UI components. Resolved HTML nesting issues that caused layout bleeding, redesigned help tooltip icons for a premium aesthetic (24px size + smooth transitions), and resolved pseudo-element conflicts. Also escaped literal script/style tags in help text to satisfy security regression gates. **Customer-facing**: The admin builder is now more organized, accessible, and visually polished.', 'tc-data-tables'); ?></li>
                    <li><strong>4.60.0</strong> — <?php _e('Maintainer feature: Phase 4 JS test surface, slice 2b (#672). Advances the 100% coverage initiative by providing a robust testing infrastructure for the frontend JavaScript surface. Shipped a JS testing shim (`tests/js/_load-frontend.js`) that dynamically imports `assets/js/frontend.js` into the `jsdom` testing environment, shimmed with jQuery attached to `window`. Migrated shape tests in `totals-reducer.test.js`, `url-filters.test.js`, `pagination-labels.test.js`, and `storage-key.test.js` to perform actual assertions against `GravityTable.prototype` methods. Updated `tools/coverage-gate.php` to merge the JS Clover XML report emitted by `vitest` with the PHP coverage report. Added `assets/js/frontend.js` to `docs/COVERAGE_BASELINE.md` with an initial tested coverage baseline of `12.38%`. Executed `npm test -- --coverage` via the `tools/test-all.sh` suite wrapper, verifying that vitest assertions pass and V8 coverage is tracked properly. No customer-facing change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.59.0</strong> — <?php _e('Feature (slice 4 of 4 — CLOSES #568): Click-to-filter drill-down URL persistence. The final slice for #568 that makes drill-down filters persistent across page reloads and shareable via the URL. Implemented gt_df URL parameter ingestion in templates/table.php to seed initial state. Backend TC_Ajax::get_entries now ingests drilldown_filters and injects secure SQL AND EXISTS clauses into both entry retrieval and count queries, ensuring database-level parity and correct pagination. assets/js/frontend.js updated to sync active filters to the browser address bar via history.pushState and trigger AJAX reloads on filter interaction. 12 new test assertions in tests/test-issue-568-slice4-drilldown-persistence.php verify the end-to-end wiring. **Customer-facing**: filtered table views are now bookmarkable and shareable.', 'tc-data-tables'); ?></li>
                    <li><strong>4.58.0</strong> — <?php _e('Feature (slice 3 of 3 — CLOSES #549): Cell-level vertical alignment overrides. The final slice for #549 that brings the vertical alignment infrastructure into the admin builder UI and frontend runtime. Users can now override vertical alignment for individual cells via a new floating "Cell Toolbar" in the table builder preview area. Clicking any cell allows visitors to toggle vertical alignment (Top/Middle/Bottom) or revert to column default. Storage uses a nested `cell_vertical_alignments[entry_id][field_id]` map, persisted via TC_Admin::save_table with delegation to TC_Vertical_Align_Service::sanitize_cell_map. templates/table.php now fetches this map and uses TC_Vertical_Align_Service::resolve() to apply correct precedence (Cell > Column > Global). assets/js/frontend.js updated to respect these overrides during AJAX row rendering. 15 new test assertions in tests/test-issue-549-slice3-cell-override.php pin the sanitizer + template resolution + admin toolbar logic + frontend JS sync. 771/771 plugin tests pass. **Customer-facing**: you can now override vertical alignment for individual cells in the table builder.', 'tc-data-tables'); ?></li>
                    <li><strong>4.57.0</strong> — <?php _e('Feature (slice 3 of 3 — CLOSES #521): Toolbar visibility template wire-up. New $gt_tv_visible closure at the top of templates/table.php consults TC_Toolbar_Visibility_Service::is_visible against $table_config[toolbar_visibility]. Six existing component conditionals AND-extended: global_search (search input), export_buttons (toolbar export buttons block), column_filters (per-column filter row), pagination (controls), length_selector (Show N entries dropdown), info_label (entry-count line). Slice-2 AC#6 retired in same PR. With slice 3, #521 closes end-to-end across 3 slices spanning v4.7.38 → v4.56.0 → v4.57.0. Backwards compatible — legacy gates preserved + default-visible behavior for tables without toolbar_visibility set. 12 new assertions; 756/756 plugin tests pass. **Customer-facing**: unchecking any of the 6 component checkboxes now actually hides that component on the live table.', 'tc-data-tables'); ?></li>
                    <li><strong>4.56.0</strong> — <?php _e('Feature (slice 2 of 3): Toolbar visibility admin opt-in (#521). Slice 1 (v4.7.38) shipped TC_Toolbar_Visibility_Service as a pure config helper for granular per-component toolbar visibility — orphan with zero callers. Slice 2 (this release) wires the admin: 6 checkboxes for global_search / pagination / length_selector / info_label / column_filters / export_buttons in a new "Toolbar visibility" section of Display Options. Sanitizer delegates to ::normalize; admin.js posts the map. Default for unset tables: every component visible (backward-compat). Distinct from legacy show_search / show_pagination booleans which still work — additive + finer-grained. Slice 3 will wire templates/table.php. Loop session ends at this release; handoff at root LOOP_HANDOFF.md. 18 new assertions; 744/744 plugin tests pass. No visual change yet.', 'tc-data-tables'); ?></li>
                    <li><strong>4.55.0</strong> — <?php _e('Feature (slice 4j of N — feature-complete for #517 / #613 write path): Push-back permission gate. The final layer per #613 roadmap. maybe_pushback_to_airtable now does a current_user_can check at the very TOP of the helper — before rate limiter, conflict pre-fetch, and write. Default cap manage_options; filterable via gt_airtable_writeback_capability (e.g. edit_others_posts for editor-role write). Denied path: appends "direction => denied" event to audit log + safe_log + return; (no rate-limit consumption, no HTTP). Audit log now distinguishes FOUR push outcomes: push, conflict, throttled, denied. With slice 4j, all 7 layers from #613 scope are wired (sync_direction + record-id mapping + audit log + conflict resolution + rate limiting + permission gate). 16 new assertions; 726/726 plugin tests pass. **Customer-facing**: non-privileged users can no longer trigger Airtable writes; admins see denied events in audit log.', 'tc-data-tables'); ?></li>
                    <li><strong>4.54.0</strong> — <?php _e('Feature (slice 4i of N): Airtable rate-limiter wire-up (#517 / #613). Slice 4h shipped the limiter as orphan; slice 4i (this release) wires it. In maybe_pushback_to_airtable: BEFORE any HTTP call, consult should_throttle. If at-or-above the 5 req/s per-base cap, append "direction => throttled" event to audit log + safe_log warning + return; (no HTTP). AFTER each HTTP call (fetch_single_record + update_record), record_request to track the bucket. Local GFAPI update already succeeded; only the upstream push gets deferred. Customers can re-trigger by editing again once the window slides forward (~1s). Slice-4h AC#10 retired in same PR. 13 new assertions; 710/710 plugin tests pass. **Customer-facing**: push-back defers gracefully instead of hitting Airtable 429s. Audit log distinguishes push / conflict / throttled.', 'tc-data-tables'); ?></li>
                    <li><strong>4.53.0</strong> — <?php _e('Bugfix + Feature. Bugfix: Airtable submenu link in the admin menu pointed at /wp-admin/gravity-tables-airtable (404) instead of /wp-admin/admin.php?page=gravity-tables-airtable. Root cause: priority collision between the parent menu registration and the submenu hook (both at default priority 10; order depended on file load). Fix: explicit priority 20 on the submenu hook. Feature (slice 4h of N): Airtable rate-limiter service (#517 / #613). Pure per-base sliding-window rate limiter that protects against 429 Too Many Requests (Airtable: ~5 req/s per base). Public API: should_throttle, record_request, option_key (per-base, sanitized), max_requests_per_window (=5), window_seconds (=1), clear. Per-base option storage; reader/writer/deleter callables injectable. Sliding window prunes expired timestamps on every read/write. Per-base isolation. Orphan-with-test-contract: AC#10 pins no production caller yet; slice 4i wires into push helper. 27 new assertions; 695/695 plugin tests pass. No customer-facing change yet.', 'tc-data-tables'); ?></li>
                    <li><strong>4.52.0</strong> — <?php _e('Feature (slice 4g of N): Airtable conflict-detector wire-up — optimistic locking for push-back (#517 / #613). Slice 4f shipped the detection helpers as orphan; slice 4g (this release) wires them. In maybe_pushback_to_airtable, before update_record: fetch_single_record to read current Airtable lastModifiedTime, compare against local baseline via detect_conflict. On conflict: append "direction => conflict" event to audit log + skip update_record. On no-conflict: proceed; AFTER successful update, snapshot the new lastmod as baseline. Probes canonical field names (Last Modified Time / Last Modified / Modified / Updated / Updated At). Fail-open semantics preserved. Slice-4f AC#7 retired in same PR. 13 new assertions; 668/668 plugin tests pass. **Customer-facing**: pushes skip silently-conflicting overwrites; admins see conflicts in the audit log.', 'tc-data-tables'); ?></li>
                    <li><strong>4.51.1</strong> — <?php _e('Bugfix: Admin builder page truncated past Display Options. A translatable help-text span in admin/views/table-builder.php (Print settings master toggle) contained a literal &lt;style media="print"&gt; token; PHP echoed it verbatim, browser parsed it as a real opening style tag mid-page and ate the rest of the document as CSS. Symptom: Display Options rendered, then nothing — the rest of the builder UI silently vanished. Side-effect: WP core Underscore.js Site Icon template leaked through producing a {{ data.url }} 404. Fix: replace &lt; / &gt; with HTML entities. 4 more instances of the same pattern audited and fixed in admin/views/documentation.php. New regression test sweeps both view files. 655/655 plugin tests pass.', 'tc-data-tables'); ?></li>
                    <li><strong>4.51.0</strong> — <?php _e('Feature (slice 4f of N): Airtable conflict-detector service (#517 / #613). Pure detection helpers for the future optimistic-locking write path. Snapshots Airtable record\'s lastModifiedTime as a baseline keyed by local entry id; before pushing, compares remote vs baseline. Public API: parse_lastmod, detect_conflict (FAIL-OPEN when either side missing), baseline_storage_key, snapshot_baseline, load_baseline. Reader/writer callables injectable. Orphan-with-test-contract: no production caller yet. Slice 4g wires snapshot-on-read + check-before-write. Slice 4h owes rate limiting; slice 4i owes permission gate. 31 new assertions; 645/645 plugin tests pass. No customer-facing change yet.', 'tc-data-tables'); ?></li>
                    <li><strong>4.50.0</strong> — <?php _e('Feature (slice 4e of N): Airtable audit log wire-up + admin view (#517 / #613). Slice 4d (v4.49.0) shipped the audit log service. Slice 4e (this release) wires it into the slice-4c maybe_pushback_to_airtable helper so every push attempt (success + failure) records an event, and adds a "Recent push activity" section on the existing Airtable settings page showing the last 25 events as a table (Time / Entry / Record / Result with HTTP code / Detail). Append fires AFTER update_record regardless of outcome. Slice-4d AC#8 retired in same PR. Slice 4f+ adds conflict resolution + rate limiting + permission gate per #613. 13 new assertions; 614/614 plugin tests pass. **Customer-facing**: admins can now view recent Airtable push activity without grepping debug.log.', 'tc-data-tables'); ?></li>
                    <li><strong>4.49.0</strong> — <?php _e('Feature (slice 4d of N): Airtable audit log service (#517 / #613). Slice 4c (v4.48.0) wired push-back with fire-and-log semantics — useful but ephemeral. Slice 4d (this release) adds TC_Airtable_Audit_Log_Service: structured audit log of push events, capped at 100 most-recent in a single non-autoloaded option (gt_airtable_audit_log). Public API: append (auto-stamps timestamp, most-recent-first, drops oldest at cap), load (limit param), clear, option_key, max_entries. Reader/writer/deleter injectable for tests. Orphan-with-test-contract: no production caller yet. Slice 4e will wire into maybe_pushback_to_airtable + add an admin UI surface. Slice 4f+ adds conflict resolution + rate limiting + permission gate per #613. 26 new assertions; 601/601 plugin tests pass. No customer-facing change yet.', 'tc-data-tables'); ?></li>
                    <li><strong>4.48.0</strong> — <?php _e('Feature (slice 4c of N — first customer-facing two-way slice): Airtable push-back AJAX wire (#517 / #613). Inline-edit changes PATCH-push to the configured Airtable base when all four gates pass: sync_direction in (push_only, two_way), credentials stored, airtable_record_id_field set, entry has non-empty value in that field. New per-table airtable_record_id_field setting (GF field id, alphanumeric + dot for composite ids). New private maybe_pushback_to_airtable helper invoked at the end of update_entry\'s success path. Fire-and-log: failures safe_logged at warning; local response unaffected. GF field LABELS map to Airtable column names by default. Slice-4a + slice-4b orphan invariants both retired in this same PR. Slice 4d+ adds conflict resolution + rate limiting + audit log per #613. 15 new assertions; 575/575 plugin tests pass. **Customer-facing**: with all four gates satisfied, inline-edits push to Airtable.', 'tc-data-tables'); ?></li>
                    <li><strong>4.47.0</strong> — <?php _e('Feature (slice 4b of N): Per-table sync_direction admin opt-in (#517 / #613). Slice 4a (v4.46.0) shipped the underlying PATCH service. Slice 4b adds the per-table `sync_direction` admin option (whitelist: pull_only default / push_only / two_way; invalid values fall back to pull_only as the safest default). New "Airtable sync direction" dropdown in the table builder Display Options + sanitizer + admin.js round-trip. Slice 4c will wire the AJAX endpoint to consult this when push_only or two_way (also picking a source-row-id strategy). Slice 4d+ adds conflict resolution + rate limiting + audit log per #613. 17 new assertions; 560/560 plugin tests pass. No customer-facing runtime change yet — slice 4c owes the AJAX wire.', 'tc-data-tables'); ?></li>
                    <li><strong>4.46.0</strong> — <?php _e('Feature (slice 4a of N): Airtable update-record (PATCH) method (#517 / #613 territory). Slices 1-3b shipped the Airtable READ path end-to-end. Slice 4a (this release) ships the WRITE half: TC_Airtable_Sync_Engine::update_record(base, table, record_id, fields, token, [http]) builds the URL via the slice-1 builder, posts a PATCH with Bearer auth + JSON body {fields: {...}} via wp_remote_request, returns the same structured shape as the read methods. Fail-closed: empty token / invalid record id / empty fields all refuse without firing HTTP. Orphan-with-test-contract: AC#9 pins the slice-4b orphan invariant. Slice 4b will add per-table sync_direction admin option (pull_only / push_only / two_way) and wire gt_update_entry AJAX to PATCH-push when applicable. Slice 4c+ adds conflict resolution, rate limiting, audit log per #613 roadmap. 23 new assertions; 543/543 plugin tests pass. No customer-facing change yet.', 'tc-data-tables'); ?></li>
                    <li><strong>4.45.0</strong> — <?php _e('Feature (slice 2 of N): Vertical alignment service production wire (#549). Slice 1 (v4.7.48) shipped TC_Vertical_Align_Service as a pure resolver helper — fully orphan with zero callers. Slice 2 (this release) wires it into production: the column_vertical_alignments sanitizer now sources its whitelist from ::alignments() (source of truth in one place); the template emits a service-derived class via ::to_class() (gt-valign-top / gt-valign-bottom; middle has no class because that\'s the browser default) ADDITIONALLY to the existing inline-style emission (purely additive — backwards compatible with custom CSS that relies on inline styles). CSS rules already shipped in slice 1.2. Slice 3 will ship the cell-override UX (per-cell toolbar that lets a visitor override the column-level alignment). 9 new assertions; 520/520 plugin tests pass. No visual change yet.', 'tc-data-tables'); ?></li>
                    <li><strong>4.44.0</strong> — <?php _e('Feature (slice 3 of 3 — CLOSES #553): WAF-safe payload client-side encode. templates/table.php applies the gt_waf_safe_payload_enabled filter (default false) and exposes the result as waf_safe_payload in $table_config. assets/js/frontend.js reads the flag at the gt_update_entry (per-field) + gt_update_entry_fields (bulk) call sites; when on, JSON.stringify + btoa-encodes the payload (entry_id + table_id + updates / fields) and posts an opaque base64 envelope under `payload`. Server (slice 2 v4.41.0) already accepts both shapes. Customers behind aggressive WAFs (Cloudflare, Sucuri, mod_security, Wordfence) opt in via add_filter(\'gt_waf_safe_payload_enabled\', \'__return_true\') so generic SQLi/XSS rules can\'t pattern-match cell content. Slice-2 AC#6 retired in same PR. With slice 3, #553 closes end-to-end across 3 slices spanning v4.7.22 → v4.41.0 → v4.44.0. 15 new assertions; 511/511 plugin tests pass. **Customer-facing**: single-filter opt-in for sites where WAFs false-positive on inline-edit payloads.', 'tc-data-tables'); ?></li>
                    <li><strong>4.43.0</strong> — <?php _e('Feature (slice 3 of 3 — CLOSES #544): Multi-row sticky header CSS emission. templates/table.php calls TC_Sticky_Rows_Service::frozen_count to read the count, then when > 1 builds inline CSS scoped to #table_instance_id with `position: sticky` + accumulated `top: calc(var(--gt-sticky-row-height, 38px) * (N-1))` + z-index ladder for each thead tr:nth-child(N) th. Single-row sticky stays on the existing v4.7.46 path. Customers can override row height per-table via --gt-sticky-row-height. Combined with gt-freeze-first-column (#534) for Excel-style top-left freeze. Slice-2 AC#5 retired in same PR. With slice 3, #544 closes end-to-end across 3 slices spanning v4.7.46 → v4.42.0 → v4.43.0. 10 new assertions; 496/496 plugin tests pass. **Customer-facing**: multi-row thead tables now show multiple frozen rows during scroll.', 'tc-data-tables'); ?></li>
                    <li><strong>4.42.0</strong> — <?php _e('Feature (slice 2 of 3): Multi-row sticky header admin opt-in (#544). Slice 1 (v4.7.46) shipped TC_Sticky_Rows_Service as a pure config helper (clamps frozen_top_rows 1..10) — fully orphan with zero callers. Slice 2 (this release) adds the "Number of header rows to freeze" number input next to the existing "Sticky header" checkbox in Display Options, wires admin.js to post the value, and adds an apply_v48_v49_sanitizers block that delegates clamping to TC_Sticky_Rows_Service::normalize. PHP view pre-fills the input value (parity with sticky_header). Slice 3 will emit per-N CSS so multiple thead rows stay sticky with accumulated top offsets, combined with freeze-first-column (#534) for an Excel-style top-left freeze. 17 new assertions; 486/486 plugin tests pass. No customer-facing change yet.', 'tc-data-tables'); ?></li>
                    <li><strong>4.41.0</strong> — <?php _e('Feature (slice 2 of 3): WAF-safe payload server-side wire (#553). Slice 1 (v4.7.22) shipped TC_WAF_Safe_Payload as a pure helper (encode = json_encode + base64_encode; decode = inverse; is_encoded = shape check). Slice 2 (this release) wires the server side: TC_Ajax::update_entry and update_entry_fields both check $_POST[payload], call is_encoded() + decode() if present, fall through to the legacy form-encoded path otherwise. Backwards compatible. Slice 3 will wire the client-side encode in inline-edit JS. Customers behind aggressive WAFs (Cloudflare, Sucuri, mod_security, Wordfence) hit false positives on inline-edit AJAX when cell content like "UNION SELECT" or "&lt;script&gt;" trips generic rules — the base64 envelope hides those tokens. 10 new assertions; 469/469 plugin tests pass. No customer-facing change yet — slice 3 owes the client-side encode site.', 'tc-data-tables'); ?></li>
                    <li><strong>4.40.0</strong> — <?php _e('Feature (slice 3 of 3 — CLOSES #568): Click-to-filter cell drill-down frontend wire-up. templates/table.php exposes drilldown_columns to JS; assets/js/frontend.js binds a delegated click handler on tbody cells in admin-opted-in columns that toggles {col, value} chips above the table and hides non-matching rows (AND semantics, exact string equality, idempotent toggle). × button on each chip removes that filter. Slice-2 AC#6 retired in same PR. With slice 3, #568 closes end-to-end across 3 slices spanning v4.7.53 → v4.39.0 → v4.40.0. URL persistence + cross-page AJAX integration deferred to optional slice 4 (only if customer feedback wants drill-downs to survive page reloads). Inline interactives (links, buttons, edit cells, etc.) excluded from the click delegate so existing UX stays intact. 12 new assertions; 459/459 plugin tests pass. **Customer-facing**: #568 fully ships.', 'tc-data-tables'); ?></li>
                    <li><strong>4.39.0</strong> — <?php _e('Feature (slice 2 of 3): Click-to-filter cell drill-down per-column admin opt-in (#568). Slice 1 (v4.7.53) shipped TC_Drilldown_Filter_Service as a pure helper (filter-state with toggle, AND-apply, URL serde) — fully orphan with zero callers. Slice 2 (this release) wires the per-column "Enable click-to-filter" checkbox + admin.js (boolean per-field <-> flat list of field_ids round-trip) + AJAX sanitizer (delegates to service::normalize_settings). Slice 3 will ship the frontend cell-click delegation + removable filter chips above the table + URL persistence via the slice-1 serde. Distinct from #373 per-column dropdown filters: drill-down is "filter by example" on a value you can see, not a list to choose from. 11 new assertions; 447/447 plugin tests pass. No runtime change yet.', 'tc-data-tables'); ?></li>
                    <li><strong>4.38.0</strong> — <?php _e('Feature (slice 4 of N — CLOSES #518): Rowspan auto-merge live runtime via AJAX response + JS renderer. AJAX response shape now includes `directives` (per-column [row_idx => {render, rowspan}] map computed via TC_Rowspan_Merge_Service). JS renderer in templates/table.php consumes the directives: skips <td> when render=false (prior row\'s rowspan covers), emits rowspan="N" when render=true && rowspan>1. With slice 4, #518 ships end-to-end — auto-merge configured in builder shows merged cells in BOTH preview AND live AJAX-rendered tables. Runs are page-scoped (pagination breaks runs). Slice-3 AC#7 retired in same PR. 14 new assertions; 436/436 plugin tests pass. **Customer-facing**: #518 fully end-to-end functional.', 'tc-data-tables'); ?></li>
                    <li><strong>4.37.0</strong> — <?php _e('Feature (slice 3 of N): Rowspan auto-merge server-side preview rendering (#518). Wires templates/table.php to consume the column_auto_merge flag from slice 2 and emit merged rowspan\'d cells in the admin builder preview path. Pre-computes directives via TC_Rowspan_Merge_Service::directives, then per-row: skip <td> when directive.render=false (prior row\'s rowspan covers it), emit rowspan="N" when render=true && rowspan>1, normal <td> otherwise. Strict equality for run detection. Slice-2 AC#6 invariant retired in same PR (template now references the service). Slice 4 will extend the AJAX response + JS renderer for live runtime; until then, only the preview reflects the configuration. 10 new assertions; 422/422 plugin tests pass. Customer-visible in admin builder preview.', 'tc-data-tables'); ?></li>
                    <li><strong>4.36.0</strong> — <?php _e('Feature (slice 2 of 3): Rowspan auto-merge per-column admin opt-in (#518). Slice 1 (v4.7.36) shipped TC_Rowspan_Merge_Service as a pure-function helper that walks values and identifies runs of consecutive duplicates. Slice 2 (this release) adds the per-column checkbox in the table builder under each column\'s config panel and wires admin.js for the full load → edit → save → reload round-trip. Save shape: column_auto_merge field_id => bool map (sanitizer already in place from slice 1). Slice 3 will do the template surgery so the runtime emits rowspan-merged cells. Configuring this today persists the flag but carries no visible change yet. 13 new assertions; 412/412 plugin tests pass. No visual change yet.', 'tc-data-tables'); ?></li>
                    <li><strong>4.35.0</strong> — <?php _e('Feature (slice 3b — first customer-facing Airtable slice): Airtable settings admin page (#517). New "Airtable" submenu under Advanced Tables. Three admin-post handlers (save / test connection / clear) verify nonce + manage_options and delegate to the slice-3a credential service and slice-2 sync engine. Test connection uses page_size=1 — cheapest probe that verifies auth. View pre-fills base + table from storage but NEVER pre-fills the token (mask-on-display). Inline notices surface success / failure with the sync engine\'s actual error message. Retires the slice-1 + slice-2 + slice-3a orphan invariants in this same PR (4-way retirement). 31 new assertions plus inversion of 4 prior ACs; 411/411 plugin tests pass. **Customer-facing**: customers can now configure Airtable connectivity entirely from wp-admin. Slice 4+ tackles two-way sync (#613).', 'tc-data-tables'); ?></li>
                    <li><strong>4.34.0</strong> — <?php _e('Feature (slice 3a of N): Airtable credential encryption + storage service (#517). Ships TC_Airtable_Credential_Service. Encrypts the Airtable API token at rest using AES-256-CBC with AUTH_KEY-derived key + random 16-byte IV per encrypt (mirrors TC_External_DB pattern). Persists base_id + table_id + encrypted token in a single non-autoloaded option (gt_airtable_credentials). Public API: encrypt, decrypt (fail-closed on tamper/garbage), store (refuses empty fields), load, clear, is_configured, option_key. Reader/writer callables are injectable so unit tests don\'t need WP. Slice 3b will layer the admin settings page + connection-test button. When 3b lands, the orphan invariants in slices 1 + 2 + 3a all retire together. 39 new assertions; 409/409 plugin tests pass. No customer-facing change yet.', 'tc-data-tables'); ?></li>
                    <li><strong>4.33.0</strong> — <?php _e('Feature (slice 2 of N): Airtable HTTP sync engine (#517). Ships TC_Airtable_Sync_Engine layered over slice-1\'s request builder. Public API: fetch_records (auto-paginates up to 100 pages = 10k rows; structured return with ok / records / http_code / error), fetch_single_record, is_retryable_status (429 + 5xx). HTTP transport, non-2xx, JSON-parse, and empty-token failures all surface through one shape. Transport is injectable for unit tests; production uses wp_remote_get. Orphan-with-test-contract: no admin caller yet. Slice 3 wires admin UI for credentials; slice 4+ tackles two-way sync (#613). 35 new assertions; 370/370 plugin tests pass. No customer-facing change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.32.0</strong> — <?php _e('Feature (slice 1 of N): Airtable request-builder service (#517). Ships TC_Airtable_Request_Builder — orphan-with-test-contract pure-function builder. Public API: build_list_url (with offset + pageSize 1-100), build_record_url (returns empty for invalid id), build_headers (fail-closed for empty token), parse_pagination_offset, sanitize_id. Pairs with existing TC_Airtable_Field_Mapper. Slice 2 wires HTTP sync; slice 3 adds admin UI for credentials; slice 4+ tackles two-way sync (#613). 27 new assertions; 335/335 tests pass. No customer-facing change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.31.0</strong> — <?php _e('Feature (slice 3 of 3 — CLOSES #599): Cascading filter frontend wire-up. wp_ajax_gt_cascading_filter_options + nopriv variant returns valid child options for chosen parent value. templates/table.php emits data-gt-cascade-* attrs on filter cells. frontend.js parent-change listener AJAX-fetches options + updates child placeholder. Closes #599 end-to-end across 3 slices. 13 new assertions; slice-1 AC#5 retired; 334/334 tests pass. 5th issue closed end-to-end this loop session.', 'tc-data-tables'); ?></li>
                    <li><strong>4.30.0</strong> — <?php _e('Feature (slice 2 of 3): Cascading filter admin chain config (#599). New "Cascading filter dropdown" feature group with cascading_filter_parent_field + cascading_filter_child_field text inputs. Sanitizer normalizes via TC_Cascading_Filter_Service::normalize_chain (rejects self-ref + empties). admin.js posts both. Slice 3 wires frontend.js to react to parent changes. 9 new assertions; 333/333 tests pass. No runtime change yet.', 'tc-data-tables'); ?></li>
                    <li><strong>4.29.0</strong> — <?php _e('Feature (slice 2b — CLOSES #536): Revision history admin restore UI. New "Revisions" row-action on tables-list with prompt-driven UX (pick index 0-4 of last 5 snapshots → confirm → redirect). admin_post_gt_action_restore_revision verifies nonce + manage_options, loads revisions via service::load, decodes payload, writes title + settings back via $wpdb->update. Closes #536 end-to-end across 3 slices (slice 1 service v4.7.x → slice 2a save-hook + storage v4.28.0 → slice 2b admin UI v4.29.0). 11 new assertions; 332/332 tests pass. Customer-visible: admins can view + restore prior table snapshots from the tables-list page.', 'tc-data-tables'); ?></li>
                    <li><strong>4.28.0</strong> — <?php _e('Feature (slice 2a of N): Revision history save-hook + storage (#536). Wires existing TC_Revision_Snapshot_Service (slice 1) so snapshots actually get captured on every save_table. New service helpers: option_key, persist, load. New gt_capture_revision_snapshot function hooks gravity_tables_after_save_table at priority 10 — builds snapshot via make_snapshot, persists via update_option (autoload=false), keeps last 5 by created_at desc in option gt_revisions_table_<id>. Slice 2b owes the admin UI for listing + restoring snapshots. 20 new test assertions; 331/331 tests pass. No customer-facing change — snapshots accumulate silently for now.', 'tc-data-tables'); ?></li>
                    <li><strong>4.27.0</strong> — <?php _e('Feature (slice 1 of 3): Cascading filter dropdown service (#599). Ships TC_Cascading_Filter_Service — orphan-with-test-contract for parent→child dropdown dependencies (Country→State→City pattern). Public API: normalize_chain (validates pair, rejects self-ref), normalize_chains, get_valid_options (distinct child values where parent matches; skips incomplete rows), render_dependency_attributes (emits data-gt-cascade-parent / data-gt-cascade-child HTML attrs). Slice 2 wires frontend.js for parent-change AJAX repopulation. Slice 3 adds admin UI for chain config. 23 new assertions; 330/330 tests pass. No customer-facing change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.26.0</strong> — <?php _e('Feature (slice 3 of 3 — CLOSES #538): Find/Replace apply endpoint + admin button. wp_ajax_gt_apply_replace iterates GFAPI::get_entries, applies service::apply per row, writes back via GFAPI::update_entry, returns {replacements_count, entries_updated}. New "Find / Replace" row-action on tables-list with two-phase prompt UX (find→count→confirm→apply). Self-contained IIFE click handler. Slice-2 AC#5 retired. 14 new assertions; 329/329 tests pass. Customer-visible: admins can run find/replace across all rows without writing PHP. With slices 1+2+3, #538 fully shipped end-to-end.', 'tc-data-tables'); ?></li>
                    <li><strong>4.25.0</strong> — <?php _e('Feature (slice 5 of 5 — CLOSES #618 end-to-end): Per-table admin UI for built-in action config. New "Per-row actions" feature group with send_email_recipient_field (text input) + per_row_webhook_url (url input). Sanitizer + admin.js wiring. Two bridge filter callbacks at priority 10: gt_send_email_recipient_field + gt_post_webhook_url read from table settings as defaults. Customer code at priority > 10 still overrides. Slice-4 AC#6 retired. 11 new assertions; 328/328 tests pass. With slices 1-5 all shipped, #618 is fully end-to-end functional like #607 — customers enable the built-in actions via gt_*_enabled filters, then configure recipient field + webhook URL per-table from the admin UI.', 'tc-data-tables'); ?></li>
                    <li><strong>4.24.0</strong> — <?php _e('Feature (slice 4 of N): Per-row post_webhook built-in action (#618). Mirrors slice 3 send_email pattern. New helper register_builtin_post_webhook + filter gt_post_webhook_enabled. Handler gt_handle_post_webhook_action verifies nonce + edit_posts, loads entry, reads URL from gt_post_webhook_url filter, POSTs JSON via wp_remote_post (15s timeout). Default payload: {event, action, table_id, row_id, entry}. Customization filter gt_post_webhook_payload. Redirects with gt_webhook_sent flag. 20 new assertions; slice-3 AC#6 retired. 327/327 tests pass. Slice 5 owes per-table admin UI for templates.', 'tc-data-tables'); ?></li>
                    <li><strong>4.23.0</strong> — <?php _e('Feature (slice 3 of N): Per-row send_email built-in action (#618). Slice 1 (v4.21.0) shipped registry; slice 2 (v4.22.0) wired template; slice 3 (this release) ships first built-in action. New helper register_builtin_send_email + filter `gt_send_email_enabled` (default false, opt-in). admin-post handler gt_handle_send_email_action verifies nonce + edit_posts capability, loads entry via GFAPI, picks recipient via filter or first email-shaped field, composes subject/body via filters, dispatches wp_mail, redirects with gt_email_sent flag. Customization filters: gt_send_email_recipient_field / gt_send_email_subject / gt_send_email_body. 22 new assertions; 326/326 tests pass. Slice 4 owes post_webhook + per-table admin UI for templates. Customer-visible: when sites opt in via add_filter("gt_send_email_enabled", "__return_true"), each row gets a Send email button.', 'tc-data-tables'); ?></li>
                    <li><strong>4.22.0</strong> — <?php _e('Feature (slice 2 of N): Per-row action button template wire-up (#618). Slice 1 (v4.21.0) shipped the registry service; slice 2 wires templates/table.php to actually render the buttons. Each row\'s `gt-actions` cell now invokes TC_Per_Row_Action_Service::render_buttons_html(table_id, entry_id, entry_data) inline alongside view/edit/delete. Customer-visible: any developer-registered action via `add_filter("gt_per_row_actions", ...)` now actually appears as a clickable button on every row. Service returns empty string when no actions registered, so existing tables render unchanged. Slice 3 (built-in send_email + post_webhook actions + admin-post handlers) pending. 13 new test assertions; slice 1 AC#6 invariant retired in same PR. 325/325 tests pass. Customer-visible behavior change: developer-registered actions now render.', 'tc-data-tables'); ?></li>
                    <li><strong>4.21.0</strong> — <?php _e('Feature (slice 1): Per-row action button registry service (#618). Ships TC_Per_Row_Action_Service — orphan-with-test-contract that developers extend via the `gt_per_row_actions` filter to add custom action buttons (send email, post webhook, trigger workflow) per row. Public API: get_actions, normalize_action, is_visible_for_user, render_buttons_html, interpolate_url. Filter shape registers id/label/url_template/capability/icon. id sanitized to alphanumeric+underscore. {table}/{row} placeholders interpolated urlencoded. Slice 2 wires templates/table.php to render buttons; slice 3 ships built-in send_email + post_webhook actions. 26 new assertions; 324/324 tests pass. No customer-facing change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.20.0</strong> — <?php _e('Feature: Find/Replace AJAX endpoint slice 2 of 3 (#538). Wraps TC_Find_Replace_Service::find_matches (slice 1, v4.7.44) as wp_ajax_gt_find_matches. Manage_options capability + nonce check. Loads rows via GFAPI::get_entries (page size 1000), delegates to service, returns {count, matches, truncated} JSON. Capped at 100 matches per response. Slice 3 will ship the apply endpoint + admin modal that calls GFAPI::update_entry. 11 new test assertions; 323/323 plugin tests pass. No customer-facing UI yet — endpoint is exposed for any custom UI to preview matches.', 'tc-data-tables'); ?></li>
                    <li><strong>4.19.0</strong> — <?php _e('Feature: Per-table password protection — slice 3 of 3 (#607). The frontend gate. Closes #607 fully end-to-end. Visitors with a password-protected table now see a password form instead of the table; correct entry reveals it for ~24h via signed cookie. Three paths: (1) Forget-password GET ?gt_pw_forget=<id> clears cookie. (2) POST verifies via TC_Table_Password_Service::verify, mints signed token via generate_unlock_token (using wp_salt(auth) as secret), setcookie\'s for 24h. (3) Cookie path delegates to is_unlocked. WP nonce protects POST. Defense in depth: gate fires after settings JSON decode but BEFORE any data fetch / template include — table data never enters HTML when locked. Form input is type="password" autocomplete="current-password" with hidden table_id + nonce fields. 19 new test assertions; 322/322 tests pass. With slices 1 (v4.10.1) + 2 (v4.18.0) + 3 (this release) all shipped, the feature is end-to-end functional. Customer-visible behavior change: tables with a password now block visitors.', 'tc-data-tables'); ?></li>
                    <li><strong>4.18.0</strong> — <?php _e('Feature: Per-table password protection slice 2 of 3 (#607). Wires the admin "Password (optional)" field on the table builder + AJAX save sanitization. Slice 1 (v4.10.1) shipped TC_Table_Password_Service with bcrypt hash/verify; slice 2 (this release) makes it persistable from the admin UI. New "Password protection (#607)" feature group in Display Options. Input is type="password" with autocomplete="new-password". Three sanitizer paths: empty = preserve existing, non-empty = hash + store, sentinel "__GT_CLEAR__" = remove. Hash never echoed back to the browser. Sanitizer in apply_v48_v49_sanitizers delegates to TC_Table_Password_Service::hash. Settings key: table_password_hash. Slice 3 (frontend gate — visitor sees password form, signed-cookie verification, table data stays out of HTML when locked) is still owed; this release stores the password but does NOT yet block visitors. 14 new test assertions; 321/321 tests pass. Customer-visible UI change: new field on the table builder.', 'tc-data-tables'); ?></li>
                    <li><strong>4.17.0</strong> — <?php _e('Maintainer feature: Phase 4 JS test surface slice 2a (#672). Grows JS suite from 2 to 5 starter tests + wires `npm test` into tools/test-all.sh. New shape tests: totals-reducer (Sum/Avg/Min/Max/Count/Count distinct), storage-key (localStorage key derivation), pagination-labels (label resolution + interpolation). tools/test-all.sh now runs `npm test --silent` after the PHP suite when package.json + node_modules are both present; skip-mode (clean exit) when node_modules missing — keeps loop velocity up. Slice 2b pending: replace shape tests with real frontend.js method tests via IIFE shim, extend tools/coverage-gate.php for JS clover. 12 new test assertions; 320/320 plugin tests pass. No GitHub Actions involvement. No customer-facing change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.16.0</strong> — <?php _e('Maintainer feature: Phase 4 JS test surface slice 1 (#672). Fills the JavaScript counterpart of the PHP coverage chain. Ships package.json (vitest + @vitest/coverage-v8 + jsdom devDeps; npm test / test:watch / test:coverage scripts), vitest.config.js (include tests/js/**/*.test.js, jsdom env, V8 coverage with HTML+Clover+text reporters into build/coverage/js/, scope assets/js + admin/js excluding *.min.js), tests/js/sanity.test.js (vitest+jsdom smoke), tests/js/url-filters.test.js (URL-filter parser shape test). docs/COVERAGE.md gets a new JS section documenting the stack, install + run (`npm install && npm test`), and slice-2 deferred follow-ups (grow to ≥5 frontend.js method tests, wire npm test into tools/test-all.sh, pull JS clover into tools/coverage-gate.sh). Local-only — no GitHub Actions. 18 new test assertions; 319/319 plugin tests pass. Coverage initiative infrastructure layer is now complete; only pcov install + slice 2 expansion + Phase 5 fills remain. No customer-facing change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.15.0</strong> — <?php _e('Maintainer feature: Phase 3 loop-driven coverage gate (#671). Ships tools/coverage-gate.sh + tools/coverage-gate.php + tools/coverage-allowlist.txt. Gate runs as part of LOOP_STRATEGY.md step [8] before the loop merges its own PR: parses build/coverage/clover.xml against docs/COVERAGE_BASELINE.md, reports regressions and improvements, exits non-zero if any file drops below baseline. PHP helpers exposed for testability: gt_gate_parse_clover, gt_gate_parse_baseline, gt_gate_compute_delta. Skip-mode: when clover.xml is missing/empty (no pcov yet) or baseline holds no numeric rows, the gate exits 0 — keeps loop green until real data flows. Allowlist format enforces `# justification` per entry. The 100% coverage initiative\'s infrastructure layer is now complete (#668 → #670 → #679 → #682 → #669 → #671); only pcov install + #672 JS surface remain. 26 new test assertions; 318/318 plugin tests pass. No GitHub Actions involvement. No customer-facing change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.14.0</strong> — <?php _e('Maintainer feature: Phase 2a baseline coverage report (#669). Ships docs/COVERAGE_BASELINE.md as the first reproducible coverage-baseline snapshot the future loop-driven gate (#671) will measure deltas against. Plus phpunit.xml.dist testsuite scoped to *Test.php (so PHPUnit doesn\'t auto-load the legacy runner.php-style test-issue-*.php as TestCase classes) and PHPUnitShimTest annotated with @runTestsInSeparateProcesses + @preserveGlobalState disabled (so class redeclarations between sequential test-issue files don\'t collide). Real per-file numbers pending pcov install on the dev box (pecl install pcov requires `brew install pcre2` first on macOS — documented in COVERAGE_BASELINE.md). 16 new test assertions; 317/317 plugin tests pass. Phase 3 #671 (loop-driven gate) now unblocked. No customer-facing change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.13.3</strong> — <?php _e('Maintainer fix (#682): bootstrap WP_INT_TEST guard for Elementor file-top hook registration. tests/bootstrap.php requires tablecrafter.php which load-time-includes class-tc-elementor.php, whose file-top block calls did_action() + add_action() to wire the Elementor widgets/register hook. Under PHPUnit + WP_Mock those WP functions don\'t exist when the require chain runs, so phpunit fataled. Fix wraps the block with !defined(WP_INT_TEST) || !WP_INT_TEST. Production loads still register the hook. 6 new test assertions; 316/316 plugin tests pass. Unblocks vendor/bin/phpunit end-to-end runs (combined with #679 composer.lock + #670 shim + #668 coverage tooling). Phase 2a #669 (baseline report) is now a fast follow-up. No customer-facing change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.13.2</strong> — <?php _e('Maintainer fix (#684): tools/freemius-release.sh switched from HMAC-developer-scope signing to Bearer-auth + product-scope endpoints. The dashboard\'s product Settings page exposes pk_/sk_ keys (developer-scope HMAC) and an API Bearer Token (product-scope REST). The HMAC path returned HTTP 401 against product-scope keys. New endpoints: POST /v1/products/<id>/tags.json (upload) + PUT /v1/products/<id>/tags/<tag_id>.json with body {"release_mode":"released"}. Credential file simplified to FS_BEARER_TOKEN + FS_PLUGIN_ID. tools/freemius-sign.php (HMAC helpers) preserved for any future developer-scope use case but no longer the primary path. 22 new test assertions; 315/315 plugin tests pass. Also: LOOP_STRATEGY.md updated with rule #15 (automated tests are the only merge gate, no human-review required) and rule #16 (small + frequent, default ScheduleWakeup 1200s, target ≤20 min wall-clock per iteration). No customer-facing change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.13.1</strong> — <?php _e('Maintainer fix (#679): regenerate composer.lock to match composer.json. Lock had drifted — Freemius SDK was 2.13.0 but constraint had bumped to ^2.13.1, and dev-deps (phpunit/phpunit, mockery/mockery, 10up/wp_mock) weren\'t in the lock at all. `composer install --dev` now works on a clean checkout, vendor/bin/phpunit is installed (9.6.34), end-to-end PHPUnit shim from #670 starts working. 12 new test assertions pin the lock-file contract. 315/315 tests pass. Follow-up #682 tracks the remaining bootstrap-vs-WP-Mock-loading-order issue. No customer-facing change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.13.0</strong> — <?php _e('Maintainer feature: Phase 2b of the 100% coverage initiative (#670). PHPUnit shim that lets the existing 313+ custom-runner test-issue-*.php suite drive PHPUnit\'s coverage instrumentation, so the pipeline #668 stood up starts producing real coverage data. tests/PHPUnitShimTest.php is a TestCase with a @dataProvider yielding every test-issue file path; for each file, resets the runner globals, sets TC_PHPUNIT_SHIM, requires the file, asserts $failed === 0 + $passed > 0. tests/helpers/runner.php\'s shutdown function early-returns when the shim flag is set (without that, every loaded file would terminate PHPUnit mid-suite). The original `php tests/test-issue-NNN.php` invocations and `tools/test-all.sh` are unchanged — the shim is opt-in via the constant. 14 new test assertions; 314/314 plugin tests pass. Follow-up #679 tracks the composer.lock regeneration needed for end-to-end PHPUnit runs. No customer-facing change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.12.0</strong> — <?php _e('Maintainer feature: Phase 1 of the 100% coverage initiative (#668). Wires PHPUnit\'s <coverage> configuration into phpunit.xml.dist, ships tools/coverage.sh as the local entry point, and documents install + run in docs/COVERAGE.md. Backend choice: pcov preferred (10x faster than Xdebug), Xdebug as fallback. Reports: build/coverage/html/ (HTML), build/coverage/clover.xml (for the loop-driven gate #671), and a stdout text summary. Coverage scope: includes/, templates/, admin/, plugin entry; excludes vendor/, build/, node_modules/, tests/, tools/. 19 new test assertions pin every artifact. First run will report near-zero because the 313 existing test-issue-*.php files use the custom gt_assert/describe/it runner, not PHPUnit\'s TestCase — closing that gap is Phase 2b (#670). No customer-facing change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.11.0</strong> — <?php _e('Maintainer feature: Local-shell Freemius release automation. Replaces the prior browser-driven dashboard flow with tools/freemius-release.sh + tools/freemius-release.php + tools/freemius-sign.php. Signs requests per Freemius\'s HMAC-SHA256 scheme (METHOD\\nCONTENT_MD5\\nCONTENT_TYPE\\nDATE\\nPATH; base64-url-encoded HMAC; FS or FSP Authorization prefix), uploads the built zip via multipart POST to /v1/developers/<id>/plugins/<id>/tags.json, then PUTs {"is_released":true} on the new tag. Credentials sourced from ~/.config/gravity-tables/freemius.env or .freemius-release.env (gitignored). --dry-run prints the planned curl invocations without making the API call. 163 signing-helper assertions + 19 integration assertions cover the help text, missing-creds error path, dry-run plan output, and zip-not-found error path. Also retires the prior ".20 cap" versioning rule for standard semver per Freemius\'s documented expectation: minor for new features, patch for bug fixes, major for breaking changes. v4.10.1 was a misclassification (new feature shipped as patch); v4.11.0 going forward applies semver correctly. No customer-facing runtime change in this release.', 'tc-data-tables'); ?></li>
                    <li><strong>4.10.1</strong> — <?php _e('Feature (slice 1 of N): Per-table password protection — foundational service (#607). First PR-based iteration under the new TDD + PR-only workflow. Ships TC_Table_Password_Service: bcrypt password hash + verify, deterministic cookie naming (gt_table_pw_<id>), and HMAC-signed unlock-token generation that lets a visitor cookie verify against the current stored hash without a server-side session table. Token format <issued_at>.<base64url(hmac_sha256(table_id|stored_hash|issued_at, secret))>. Rotating the password (or the site secret) invalidates every existing token; clock-skew rejects future-issued tokens; constant-time hash_equals comparison; expired tokens rejected at issued_at + ttl. 38/38 service tests pass. No production wiring yet — slice 2 will add the admin "Password (optional)" field; slice 3 will wire the frontend gate. Customers see no behavior change in v4.10.1.', 'tc-data-tables'); ?></li>
                    <li><strong>4.10.0</strong> — <?php _e('Minor bump per the .20 cap rule (final v4.9.x patch was v4.9.20). UX polish: the "Recent Versions" list in this docs viewer was now 38+ entries long, forcing customers to scroll a wall of text. Collapsed by default to the 5 most recent versions; click "Show full version history" to expand. Self-contained inline IIFE handles the toggle (adds/removes the gt-recent-collapsed attribute on the <ul>; CSS hides all but the first 5 items when the attribute is set). The button label flips between "Show full version history" and "Show recent only" so the toggle direction is clear. Pure UX polish; no runtime or admin behavior changes. The full per-version list is still here in the source — just visually folded by default.', 'tc-data-tables'); ?></li>
                    <li><strong>4.9.20</strong> — <?php _e('Feature (slice 2 of #556): Per-column detail-only flag persistence. TC_Detail_Rows_Service shipped with 44 passing tests + normalize_column / partition_columns helpers but no admin caller. Slice 2 ships admin toggle + sanitizer + persistence — customers can configure today; slice 3 (runtime expand/collapse render) lands later. Help text is honest: "Configuring this today carries no visible effect — it stages the per-column flag for slice 3." Sanitizer delegates to TC_Detail_Rows_Service::normalize_column. Template injects column_detail_only into $table_config so frontend has the hook ready. Per the .20 cap rule, next iteration rolls to v4.10.0.', 'tc-data-tables'); ?></li>
                    <li><strong>4.9.19</strong> — <?php _e('Discovery: dismissible "What\'s new in v4.9.x" admin notice on the tables-list page. After 19 patch iterations across the v4.8.x/v4.9.x audit thread, customers had no clear pointer to the new features. New notice block lists 8 customer-facing improvement areas (Bulk migration, multi-column sort, layout controls, filter persistence, cell types, SEO/accessibility, defensive guard, pagination labels) + "View highlights →" deep-link to admin.php?page=gravity-tables-docs#whats-new. Per-user dismissal via gt_v49_highlights_dismissed user_meta; AJAX handler in tablecrafter.php persists the click. Customers discovering the plugin for the first time see what shipped recently; admins who already know can dismiss in one click.', 'tc-data-tables'); ?></li>
                    <li><strong>4.9.18</strong> — <?php _e('Housekeeping + docs polish: closed 14 resolved implementation issues on the GitHub tracker (#650, #651, #652, #653, #654, #655, #656, #657, #658, #659, #661, #662, #663, #664) — each had been wired in production across the v4.7.197-v4.8.13 audit thread but stayed open in the tracker. Plus added a curated "v4.9.x customer-facing highlights" subsection at the top of the in-admin What\'s New block: eight bullet groups summarizing the directly customer-facing improvements landed across v4.8.x and v4.9.x. The existing per-version list stays as the deeper-detail reference. Pure documentation + tracker hygiene; no code changes.', 'tc-data-tables'); ?></li>
                    <li><strong>4.9.17</strong> — <?php _e('Feature (slice 2b): Bulk-import-bundle admin form (#522). Closes #522 fully — round-trip migration (Export → Import) now in production. New "Import bundle" button on the tables-list page expands a <details> block with file upload + conflict-resolution policy (Skip / Overwrite / Create as new). admin-post handler does permissions + nonce, validates schema via service::validate, checks compatibility via is_compatible(), iterates tables[], routes each via service::resolve_conflict to insert / update / skip. 5-minute result transient drives a post-redirect notice with {created, updated, skipped, failed} counts. Use "Create as new" for cross-site migration to avoid id collisions.', 'tc-data-tables'); ?></li>
                    <li><strong>4.9.16</strong> — <?php _e('Feature (slice 2a): Bulk-export-all admin button (#522). TC_Bulk_Migration_Bundle_Service shipped from v4.7.39 with 38 passing tests, a complete bundle envelope, and validate/conflict-resolution helpers — but no admin caller. New "Export all to JSON" button on the tables-list page (alongside Add New / Import from CSV). admin-post handler loads all active tables, decodes settings JSON into a sibling field for diffing, calls service::build, streams as a pretty-printed JSON download. Slice 2b (Import with conflict-resolution policy dropdown) deferred. Use cases: staging→production deploys, pre-change backups, cross-site config sharing.', 'tc-data-tables'); ?></li>
                    <li><strong>4.9.15</strong> — <?php _e('Feature: Visitor-side length selector ("Show N entries" dropdown). Closes the length_selector gap from TC_Toolbar_Visibility_Service\'s canonical 6-component list (the other 5 mapped to existing toggles; length_selector had no existing setting). New "Show length selector" checkbox + CSV options input in Display Options. Default options 10/25/50/100/All; -1 = All. Template emits a <select> inside .gt-pagination-container before .gt-pagination-info. frontend.js mutates this.config.per_page on change. Sanitizer accepts -1 sentinel + 1-100000 ints; dedupes. Off by default — existing tables stay locked to the admin-set page size.', 'tc-data-tables'); ?></li>
                    <li><strong>4.9.14</strong> — <?php _e('Defensive fix: TC_Data_Integrity_Guard wired into the AJAX save path (#557). The service shipped from v4.7.24 with 19 passing tests as a runtime guard against silent / catastrophic data loss on table save (mirrors competitor failure modes — Supsystic-style "Lost all my tables" 1-star reviews). The docblock said the guard "is invoked from the AJAX save handler BEFORE any UPDATE" — but until v4.9.14 nothing invoked it. New code in TC_Ajax::save_table() before the save call: when updating, fetch the existing-table snapshot, compare incoming payload via assert_safe_table_save. When the service flags a destructive shape (empty title overwriting populated; missing columns when existing had >0), abort with HTTP 409 + the error envelope. Paranoid by design — false positives are recoverable, false negatives are catastrophic.', 'tc-data-tables'); ?></li>
                    <li><strong>4.9.13</strong> — <?php _e('Feature: Whole-table collapsible toggle (#177). TC_Collapsible_Service shipped with 14 passing tests, an accessible toggle-button HTML emitter, body-wrapper attrs emitter, and an inline-script with localStorage persistence — but no production caller. New "Collapsible table" feature group in Display Options with two checkboxes (Make collapsible + Default to collapsed). Sanitizer in apply_v48_v49_sanitizers. Template emits via service\'s get_toggle_button_html + get_body_wrapper_attrs and wraps the gt-table-wrapper. Self-contained IIFE handles click toggle + localStorage; deliberately avoids the service\'s DataTable.columns.adjust call (plugin doesn\'t use DataTables for own tables). CSS for chevron + body[hidden] respects prefers-reduced-motion. Off by default — existing tables unchanged.', 'tc-data-tables'); ?></li>
                    <li><strong>4.9.12</strong> — <?php _e('Feature: Persistent filter state via browser localStorage (closes a v4.9.4 docs-sweep flag). New per-table "Persist filters in browser localStorage" toggle in Display Options. When on, visitor\'s search box value + per-column filter selections save to localStorage on every mutation; restored on next visit before the first AJAX. Sort state is intentionally NOT persisted (transient; v4.9.11 default-sort handles initial sort). URL pre-filters take precedence — restore merges, doesn\'t replace. Four new methods: _filterStateStorageKey, persistFilterStateLocal, restoreFilterStateLocal, clearFilterStateLocal. Wired into search handlers + applyAdvancedFilter + clearFilters. Off by default — existing tables unchanged.', 'tc-data-tables'); ?></li>
                    <li><strong>4.9.11</strong> — <?php _e('Feature: Per-table default sort column + direction admin UI. TC_Default_Sort_Service shipped with 18 passing tests (#115 + #391) but no production caller — the GravityTable JS constructor hardcoded date_created/desc as the universal initial sort. New "Default sort" feature group in Display Options between Pagination Labels and Multi-column Sort. Two controls: column dropdown (driven by form fields, same pattern as Top-N) + direction (Asc/Desc). Sanitizer in apply_v48_v49_sanitizers. Template injects via service\'s get_sort_column/direction. frontend.js init() overrides this.sortField/sortOrder/sortStack from config when default_sort_column is set, before the first loadEntries(). Empty falls through to legacy default. 18/18 service tests still pass.', 'tc-data-tables'); ?></li>
                    <li><strong>4.9.10</strong> — <?php _e('Feature: Per-table row height + header height + overflow mode admin UI. TC_Row_Height_Service shipped with 21 passing tests, three named presets (compact / default / comfortable), CSS-value validator, and a get_css emitter, but no production caller. New "Row height" feature group in Display Options between URL pre-filtering and Border style: three controls — body row height + header row height (preset name or CSS value) + overflow mode (Ellipsis / Expand). Sanitizer in apply_v48_v49_sanitizers helper. Template emits scoped inline style with the same #gt-table-{id} → #table_instance_id string-replace bridge that v4.9.3 Border preset uses. Mobile cards mode (≤ 768px) reverts to auto. Slice 1 limitation: overflow_mode=expand has no JS click handler yet — behaves like ellipsis until the toggle ships. 21/21 service tests still pass.', 'tc-data-tables'); ?></li>
                    <li><strong>4.9.9</strong> — <?php _e('Refactor: extracted v4.8.x / v4.9.x feature sanitizers from save_table() into a new private apply_v48_v49_sanitizers helper. Test #468 enforces a 20k-char locality window for wp_cache_delete; each new sanitizer over the v4.8.x/v4.9.x audit thread pushed closer to the cap (failed twice in v4.9.7 + v4.9.8). After this refactor, save_table → wp_cache_delete distance dropped from 20,019 to 11,585 chars (8,415 chars of headroom). Sanitizer logic is byte-identical to v4.9.8 — only the location moved. Future iterations can add sanitizer blocks without re-trimming comments. Pure refactor, zero customer-facing change.', 'tc-data-tables'); ?></li>
                    <li><strong>4.9.8</strong> — <?php _e('Feature: Per-column totals-row aggregation. The legacy show_column_totals feature hardcoded a Sum-only reducer for numeric columns. TC_Formula_Service::SUPPORTED_AGGREGATIONS is [SUM, AVG, MIN, MAX, COUNT, COUNT_DISTINCT] with 22 passing tests but the JS totals-row renderer never used it. New "Totals row aggregation" select in the field-config modal General tab — Auto / Sum / Average / Min / Max / Count / Count distinct. Sanitizer whitelists against SUPPORTED_AGGREGATIONS. frontend.js::updateColumnTotals reads per-column override and applies the matching reducer. Empty per-column override falls through to legacy auto-Sum-for-numeric. Aggregation runs over currently-loaded rows (per-page when pagination is on); server-side full-result-set aggregation is future work.', 'tc-data-tables'); ?></li>
                    <li><strong>4.9.7</strong> — <?php _e('Feature: Per-column Star Rating cell type wired through. TC_Star_Rating_Service shipped with 13 passing tests but no production caller. New "Cell type" select in the field-config modal General tab (Plain text default / Star rating). admin.js modal round-trips field.cell_type; full-save assembles top-level column_cell_types map. Sanitizer whitelists ("" or "star_rating"). AJAX cell-render loop intercepts before lookup/date branches and calls TC_Star_Rating_Service::render($value) — pre-rendered SVG HTML survives through to the JS DOM insertion path. Server-side rendering means stars work without JS (HTML exports, RSS, page-cache snapshots). 13/13 service tests still pass.', 'tc-data-tables'); ?></li>
                    <li><strong>4.9.6</strong> — <?php _e('Feature: URL pre-filtering admin UI + frontend.js wire-up. TC_URL_Filter_Service shipped with a complete API and 52 passing tests across three issue contracts (#118 / #395 / #537 — including a reflected-XSS guard with JSON_HEX_TAG escaping) but no production caller. New per-table "Allow URL pre-filtering" toggle in Display Options (default OFF — opt-in). Template calls parse_filters() and injects parsed filters into $table_config[url_filters]. New applyUrlFilters method in frontend.js called before the first loadEntries — pre-populates the visible per-column filter inputs and seeds this.filters in the shape the AJAX backend expects. Use cases: bookmarkable filtered views, deep links from email campaigns, sales pages linking to a category-pre-filtered table. Example: ?gt_col_status=Active&gt_col_region=North. 52/52 URL-filter tests still pass.', 'tc-data-tables'); ?></li>
                    <li><strong>4.9.5</strong> — <?php _e('Bug fix: RTL service partially wired all along, but two halves missing. (1) enqueue_rtl_styles dependency was the legacy "gt-frontend" handle; since v4.8.14 the real handle is "gravity-tables-frontend" — fixed so load order is now enforced. (2) get_dir_attr() and get_wrapper_classes() helpers were never called — the wrapper div had no dir attribute and no .gt-rtl class, so per-table RTL styles had no scope to land on. templates/table.php now stamps the wrapper. Plus a v4.9.4 docs correction: RTL was incorrectly placed in the "still orphan" list; moved to "Wired (live in admin/runtime)". 23/23 RTL tests still pass.', 'tc-data-tables'); ?></li>
                    <li><strong>4.9.4</strong> — <?php _e('Docs hygiene: in-admin documentation updated to reflect the v4.8.x / v4.9.x audit-thread closures. Three sites had stale "not wired up" claims that the recent wire-ups invalidated: Sort Options section (multi-sort wired in v4.9.0 + v4.9.1), Cell Types section (vertical-align wired v4.8.12, border preset wired v4.9.3), and the "Customization services that exist but are not wired up yet" grab-bag (replaced with two lists — 9 wired entries + the genuine orphan remainders). Plus the "v4.7.x Series" What\'s New header updated to "Recent Versions (v4.7.x – v4.9.x)" since we\'re well past v4.7. Pure docs iteration; no runtime or admin behavior changes.', 'tc-data-tables'); ?></li>
                    <li><strong>4.9.3</strong> — <?php _e('Feature: Per-table border preset admin UI. TC_Border_Service shipped with 22 passing tests and four named presets (classic / rows_only / none / outer_only) but no production caller. New "Border style" feature group in the table builder Display Options with a single dropdown. Sanitizer whitelists the preset name; defaults to classic on unknown input. Template emits inline scoped &lt;style&gt; block calling TC_Border_Service::get_preset_settings → get_border_css; string-replaces the service\'s hardcoded #gt-table-{id} scope with the actual #table_instance_id wrapper. Default classic emits nothing — existing tables unchanged. 22/22 service tests still pass.', 'tc-data-tables'); ?></li>
                    <li><strong>4.9.2</strong> — <?php _e('Feature: Per-table print overrides admin UI (#531). TC_Print_Settings_Service shipped from v4.7.42 with a 33-test contract and a 5-key normalization (enabled / paper_size / repeat_header / row_striping / excluded_columns) but no production caller. The base print stylesheet ships defaults; this iteration adds the per-table customization layer. New "Print settings" feature group in the table builder Display Options: master toggle, paper size dropdown (Letter / A4 / Legal / A3 / Tabloid), repeat-header opt-out, row-striping opt-out, excluded-columns multi-select driven by form fields. Sanitizer delegates to TC_Print_Settings_Service::normalize. Template emits inline &lt;style media="print"&gt; block scoped to the wrapper ID when the master toggle is on; column IDs guarded with sanitize_html_class against CSS injection. 33/33 service tests still pass. When the master toggle is off (default), only the global stylesheet applies — existing tables unchanged.', 'tc-data-tables'); ?></li>
                    <li><strong>4.9.1</strong> — <?php _e('Feature (slice 2): Multi-column sort SQL refactor — secondary tiebreakers now apply at the database level (#565). v4.9.0 slice 1 shipped the shift-click UX with numbered badges but only honored the primary entry of the sort stack at SQL. The existing get_gravity_forms_entries ORDER BY block had 6 special cases (entry_id / date_created / created_by / ip / lookup fields / date with STR_TO_DATE format coercion / regular text with collation) all wrapped in inline branching. This iteration extracts the per-clause logic into a new TC_Ajax::build_single_sort_clause helper that returns just the clause body. The main query path now reads $table_config[_sort_stack] (injected from validated POST when length > 1), loops, builds clauses, implodes. Single-entry stacks produce identical SQL to the pre-refactor path so existing tables see no behavior change. With slice 2 in place, shift-click actually re-orders rows across the entire result set. 14/14 service tests + 309 plugin tests still pass.', 'tc-data-tables'); ?></li>
                    <li><strong>4.9.0</strong> — <?php _e('Feature (slice 1): Multi-column sort UX — Shift+click adds to sort stack (#565). TC_Multi_Sort_Service shipped with a 14-test contract from v4.7.156 but no production caller; admin docs honestly admitted "shift-click does nothing today." This iteration wires slice 1: new enable_multi_sort toggle (default ON), shift-click adds to sort stack (max 3) with visual numbered badges next to sort arrows, AJAX payload includes sort_stack JSON validated server-side via TC_Multi_Sort_Service::validate_sort_stack. Slice 1 limitation: only primary entry of the stack is honored at the SQL level — secondary tiebreaker SQL refactor is slice 2 work because the existing ORDER BY block has 6 special cases (entry_id / date_created / created_by / ip / lookup fields / date with format coercion / regular text). Customers see the UX immediately; secondary across all rows lands later. Per the .20 cap rule, this is the v4.9.0 major-feature-tagged minor bump. 14/14 service tests still pass.', 'tc-data-tables'); ?></li>
                    <li><strong>4.8.20</strong> — <?php _e('Polish: complete the v4.8.19 TC_Pagination_Label_Service wire-up. v4.8.19 left the JS-side loading skeleton and no_results empty-state hardcoded with a follow-up note. This iteration finishes the loop. Four JS call sites updated to read this.config.pagination_labels.loading / .no_results with defensive fallbacks: frontend.js:867 + :983 (loading skeleton in standard + server-side render paths), :1020 (renderSSPEntries empty branch), :1223 (renderEntries empty branch). The WooCommerce "Loading products..." variant at :930 and the cards/mobile empty state at :5472 are intentionally left unchanged — different semantics not in the 5-key contract. Final patch on the v4.8.x series — next iteration rolls to v4.9.0 per the .20 cap rule.', 'tc-data-tables'); ?></li>
                    <li><strong>4.8.19</strong> — <?php _e('Feature: Customizable pagination labels admin UI. TC_Pagination_Label_Service shipped with the canonical 5-label contract (info_text / previous_label / next_label / no_results / loading) and 29 passing tests but zero production callers — the JS pagination renderer hardcoded "Showing X to Y of Z entries" in English. Wired end-to-end: new "Pagination labels" feature group in Display Options with five text inputs (empty = use plugin default, partial overrides work). Server-side sanitizer runs each label through sanitize_text_field. templates/table.php injects the resolved labels into $table_config[pagination_labels]; PHP-rendered prev/next buttons and initial info_text now read from the map. frontend.js::updatePagination reads the info_text label with {start}/{end}/{total} token expansion. Useful for translation and branding. JS-side loading skeleton + no_results sites not yet wired (multiple call sites need a coordinated pass) — slated for follow-up. 29/29 service tests still pass.', 'tc-data-tables'); ?></li>
                    <li><strong>4.8.18</strong> — <?php _e('Feature: Schema.org JSON-LD admin UI (closes #547 slice 2 admin gap). TC_Schema_Service shipped runtime emission in v4.7.66 (templates/table.php:370 outputs valid &lt;script type="application/ld+json"&gt;) but no admin UI ever landed — customers had to hand-edit the saved settings JSON. New "SEO / Structured Data" feature group in the table builder with a Schema type dropdown: Off / Table / Dataset. admin.js save+load wired through settings.schema.schema_type. Sanitizer delegates to TC_Schema_Service::normalize so the type whitelist lives in one place. ProductList / EventList pending slice 3 (column-to-property mapping UI). Same orphan-with-runtime pattern as #661-#664 and #634. 55/55 schema tests still pass.', 'tc-data-tables'); ?></li>
                    <li><strong>4.8.17</strong> — <?php _e('Feature: Customizable export filename pattern (closes #634). TC_Export_Filename_Service shipped in v4.7.37 with a test contract but no production caller — CSV/Excel exports hardcoded "gravity_tables_export_<datetime>.csv". This iteration wires the service end-to-end. New per-table "Export filename pattern" text input in Display Options. Tokens: {table_name}, {table_id}, {YYYY-MM-DD}, {YYYY}, {MM}, {DD}, {HH}, {mm}, {ss}, {HHMMSS}, {timestamp}. Export AJAX endpoint now fetches table name into _export_meta; new resolve_export_filename($table_config, $ext) helper expands the pattern via the service. All three real export paths (export_csv_streamed, export_csv, export_excel) now consult the helper. Empty pattern falls back to legacy filename for backward compat. Sanitizer strips control chars / path separators on save. 26/26 service tests still pass.', 'tc-data-tables'); ?></li>
                    <li><strong>4.8.16</strong> — <?php _e('Bug fix: PDF export button no longer downloads a deceptive .html file (closes #636). Until v4.8.15 the "PDF" toolbar button built an HTML document and downloaded it as table-export-pdf.html — customer clicks PDF, expects .pdf, gets .html. Fix: toolbarDownloadPDF now delegates to the existing Print button (#531) flow — full print-all-rows + afterprint-restore behavior. Frontend button label changed from "PDF" to "Save as PDF". Admin label updated from the v4.7.x admit-reality "Printable HTML download" copy to "Save as PDF button (opens browser print dialog)". The browser\'s native Save as PDF produces a real .pdf binary, matching customer expectations.', 'tc-data-tables'); ?></li>
                    <li><strong>4.8.15</strong> — <?php _e('Feature: Per-table Bulk Actions opt-in admin UI (closes #635 — admit-reality patched in v4.7.178). The Bulk Actions section in the table builder is now three real checkboxes (Allow bulk delete / Allow bulk export / Allow bulk edit) instead of the v4.7.178 explanatory paragraph. Default for legacy tables (bulk_actions array unset): all three checked, matching pre-#635 behavior. Save and runtime chain were already plumbed end-to-end; only the UI checkboxes were missing. Also adds a backend per-action gate to TC_Ajax::bulk_action() so direct POSTs that target a hidden action are rejected.', 'tc-data-tables'); ?></li>
                    <li><strong>4.8.14</strong> — <?php _e('Bug fix: Settings → CSS Framework dropdown wired to asset enqueue (closes #633 — admitted inert in v4.7.177). Default → frontend.css (~125 KB), Minimal → frontend-minimal.css (~3.4 KB, file already shipped), None → registers with false source so plugin contributes no CSS. class-tc-shortcode.php::register_assets reads gt_settings[css_framework] and routes accordingly. Settings-page notice updated from admit-reality copy back to a clear description.', 'tc-data-tables'); ?></li>
                    <li><strong>4.8.13</strong> — <?php _e('Feature: Per-column Link Behavior admin controls (#664). Fourth per-column orphan unlocked in four iterations (alignment → wrap mode → vertical alignment → link behavior). Nested per-column map: {link_target / link_color / link_underline}. New "Link Behavior" subsection in the General tab of the field-config modal — target select, color input, underline checkbox. Applies when the cell renders as a link.', 'tc-data-tables'); ?></li>
                    <li><strong>4.8.12</strong> — <?php _e('Feature: Per-column Vertical Alignment admin UI (#549 / #663). Third per-column orphan unlocked in three iterations (alignment → wrap mode → vertical alignment). New select on the General tab of the field-config modal: Default / Top / Middle / Bottom. Sanitizer whitelist top/middle/bottom enforced server-side. admin.js wired through column_vertical_alignments save+load. Empty values omitted to keep saved settings minimal.', 'tc-data-tables'); ?></li>
                    <li><strong>4.8.11</strong> — <?php _e('Feature: Per-column Word-wrap Mode admin UI (#520 / #662). Same Option-C pattern as #661 alignment. New select on the General tab of the field-config modal: Default / Break-word (URLs/IDs) / Hyphenate (long prose with hyphens:auto) / No wrap (ellipsis on overflow). admin.js wired through save (top-level column_wrap_modes map, omits "default" to mirror service sanitize_map) + load (applySavedSettings).', 'tc-data-tables'); ?></li>
                    <li><strong>4.8.10</strong> — <?php _e('Feature: Per-column Text Alignment admin UI (#661). column_alignments had server-side sanitizer + runtime support all along (sanitizer whitelists left/center/right/justify; template reads the {field_id => alignment} map) but no admin UI. New Text Alignment select on the General tab of the field-config modal. admin.js wired through save (top-level column_alignments map) + load (applySavedSettings). First per-column orphan unlocked via real UI rather than shortcode attributes.', 'tc-data-tables'); ?></li>
                    <li><strong>4.8.9</strong> — <?php _e('Polish: Top-N column control upgraded from text input to a real select dropdown driven by the form\'s fields. v4.8.8 shipped a text input as a deliberate scope cut. Dropdown shows "— Top-N disabled —", "Entry creation date", and one option per form field. Saved values that don\'t match any current form field are preserved as a pass-through option so renaming/removing a field doesn\'t clobber a developer-hook-set value. Same #660 ticket.', 'tc-data-tables'); ?></li>
                    <li><strong>4.8.8</strong> — <?php _e('Feature: Top-N rows admin UI shipped (#347 / #660). Closes the admin-UI gap that v4.7.199 (#652) admitted. Three controls in section 3 → Display Options: top_n_count (number, 0=disabled), top_n_column (GF field id text input), top_n_direction (desc/asc). admin.js save+load paths restored; server-side array_merge ensures developer-hook-set values continue to round-trip. Customers can now configure Top-N directly from the table builder.', 'tc-data-tables'); ?></li>
                    <li><strong>4.8.7</strong> — <?php _e('Feature unlock: 6 more shortcode attributes reachable now that #658 made the merge actually carry them through. table_width (CSS dimension), css_class (extra wrapper class), show_export, show_print, show_pagination_info (toolbar/info toggles), table_style (style slug). Examples: [gravity_table id="3" table_width="800px"], [gravity_table id="3" css_class="my-class"]. Filed as #659.', 'tc-data-tables'); ?></li>
                    <li><strong>4.8.6</strong> — <?php _e('CRITICAL bug fix: shortcode-attribute unlocks from v4.8.1 (#655) + v4.8.2 (#656) + v4.8.3 (#657) didn\'t actually work. The merge in class-tc-shortcode.php:233 only carried allowed_user_roles into $shortcode_overrides; every other shortcode attribute was silently dropped before reaching runtime. Fix: extended the override loop to include any customer-set value for the 10 unlock keys (row_link_template, print_all_rows, vertical/horizontal_scroll, expiry_*, etc.). Customers using those shortcodes now finally get the documented behavior. Filed as #658.', 'tc-data-tables'); ?></li>
                    <li><strong>4.8.5</strong> — <?php _e('Bug fix: Dropdown filter Option Sorting frequency and original modes now wired up. Closes the second half of #650 (text-filter half closed in v4.8.4). New repository method getValueCountsAndFirstSeen() returns COUNT(*) + MIN(entry_id) per value; template now sorts COUNT desc for frequency, first-seen asc for original. Admin labels reverted from admit-reality copy to clear descriptions. #650 is now fully closed — every previously-flagged inert filter control is wired through to runtime.', 'tc-data-tables'); ?></li>
                    <li><strong>4.8.4</strong> — <?php _e('Bug fix: Text-filter "Case sensitive filtering" and "Exact match only" toggles now actually work. Previously inert per #650 (v4.7.197 admit-reality fix). class-tc-ajax.php now reads filter_configurations[$field_id] inside the text-filter case branch and swaps SQL: case-sensitive uses LIKE BINARY; exact-match uses =. Default behavior unchanged. Inline admin notice updated. Closes the text-filter half of #650; dropdown sort_options frequency/original remains separate. Rolls under #632.', 'tc-data-tables'); ?></li>
                    <li><strong>4.8.3</strong> — <?php _e('Feature unlock: horizontal scroll + row-expiry (#501) now configurable per-shortcode. Five new keys: horizontal_scroll, expiry_field_id, expiry_behavior (hide/strikethrough/bottom/move_bottom), expiry_grace_days, expiry_inverse. Runtime consumers + sanitizers existed all along; admin UI was removed at some point and never restored. Same Option-A pattern as v4.8.1 (#655) + v4.8.2 (#656). Filed as #657; cross-references #501.', 'tc-data-tables'); ?></li>
                    <li><strong>4.8.2</strong> — <?php _e('Feature unlock: vertical-scroll viewport (#324) now configurable per-shortcode. Two keys registered: enable_vertical_scroll (truthy wraps table in max-height container) and vertical_scroll_max_height (CSS value like "500px" or "60vh"; defaults to "400px"). Sanitizer and runtime always existed; only the configuration surface was missing. Same Option-A pattern as v4.8.1 (#655). Filed as #656; cross-references #324.', 'tc-data-tables'); ?></li>
                    <li><strong>4.8.1</strong> — <?php _e('Feature unlock via shortcode attributes: row_link_template (#567 clickable rows), row_link_open_new_tab (#567 force-new-tab), print_all_rows (#531 print toolbar mode). Runtime was already shipping in templates/table.php:146-154; previously these were unreachable without editing the saved JSON or a developer hook. Now configurable per-shortcode: [gravity_table id="3" row_link_template="/loads/{1}"]. Same orphan-with-runtime pattern as Top-N (v4.7.199) and the 7-controls fix (v4.7.201). Filed as #655. Rolls under #632.', 'tc-data-tables'); ?></li>
                    <li><strong>4.8.0</strong> — <?php _e('Versioning rule change + Freemius SDK upgrade. Patch number now caps at .20 (will roll to next minor when exceeded; v4.7.201 was running with three-digit patches which felt runaway). Freemius WordPress SDK upgraded 2.13.0 → 2.13.1 via wholesale vendor swap from the GitHub 2.13.1 tag. Plus three composer.json fixes caught while updating the SDK constraint: license MIT → GPL-2.0-or-later (matches plugin header per v4.7.183), PHP >=7.4 → >=8.0 (matches plugin header per v4.7.192), SDK constraint pinned to ^2.13.1.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.201</strong> — <?php _e('Bug fix: 7 named admin UI controls never persisted on save. Reverse pattern from #651/#652/#653: where those bugs had admin.js reading from non-existent selectors, this had admin.js OMITTING controls that DO exist in views. show_toolbar_copy/csv/excel, show_pdf_export, processing_mode, template_type, data_source_type — all interactive in the UI but silently dropped from the save payload. Highest impact: export toolbar buttons never appeared on the frontend, Server-side processing never engaged for large tables, Comparison/Pricing template was unreachable. Runtime layer was fully implemented all along; only the admin save+load was broken. Fix: added all seven keys to both save-payload blocks and the load block. Filed as #654; rolls under #632.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.200</strong> — <?php _e('Bug fix: flip_breakpoint clobbered with hard-coded 768 on every re-save. Same dead-selector pattern as v4.7.198 (#651) and v4.7.199 (#652) — admin.js read from a non-existent input, NaN || 768 fallback wrote 768 every save, overwriting any developer-hook-set value. Removed the line from the save payload; server-side array_merge preserves existing values; runtime template default remains 768. After this fix every key admin.js writes has a matching control in admin/views/. Dead-selector audit clean. Filed as #653; rolls under #632. Milestone: v4.7.200.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.199</strong> — <?php _e('Bug fix: Top-N feature (#347) silently destroyed by every table re-save. Top-N ships at runtime (class-tc-ajax.php:999-1011) but has no admin UI; admin.js still read from non-existent selectors and saved zero defaults, overwriting any values set via developer hook or DB edit. Same regression pattern as v4.7.198 (#651) bulk_actions. Fix: removed the three top_n_* lines from admin.js save payload — server-side array_merge with incoming-wins preserves existing values when keys are omitted. Filed as #652; cross-references #347; rolls under #632.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.198</strong> — <?php _e('Bug fix: Tables saved after v4.7.178 rendered zero bulk-action buttons even when Show selection + Show bulk actions were both on. Regression from the #635 fix — admin.js still read deleted checkboxes, saved bulk_actions=[]. Empty array bypassed the !is_array() guard in three render paths. Fixed: server-side fallback now treats empty as missing and defaults to all three [delete, edit, export]; admin.js save path now defaults to all three when the legacy markup is gone. Filed as #651; rolls under #632.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.197</strong> — <?php _e('Live admin honesty: 3 inert filter-config controls flagged. Per-column gear → Filtering tab had checkboxes for "Case sensitive filtering" and "Exact match only" that saved values to config but had no runtime consumer (matching always uses database collation default and is always substring). Dropdown Option Sorting offered "By Frequency" + "Original Order" but the template only branched on alphabetical, so those two silently fell through. Added inline notices flagging the inert state and renamed the broken sort-options to admit they fall back. Same admit-reality pattern as v4.7.177 CSS Framework fix. Filed as #650; rolls under #632.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.196</strong> — <?php _e('Docs hygiene: docs/testing/divi5-test-matrix.md corrected (3 wrong references). Doc said class="gravity-table" — actual is class="gt-table" per templates/table.php line 1078. Asset enqueue check listed gravity-tables.css/.js — actual filenames are frontend.css (125.2KB) / frontend.js (255.8KB); no file with the doc-named asset path exists. Inline-edit check path corrected from assets/js/gravity-tables.js to assets/js/frontend.js. Filed as #649; rolls under #632.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.195</strong> — <?php _e('Docs hygiene: docs/business-plan.md rewritten + user-guide/README.md caching bullet trimmed. Tech stack claims wrong ("Vanilla JS, under 50kb" — actually jQuery + ~256KB). Pro feature list cleaned up: cascading filters → roadmap #599, PDF → print-to-PDF, Auto-Refresh dropped (fictional per #643), Role-Based Permissions softened to match real two-surface model. Phase 3 "Features Shipped" rewritten to honestly list shipping vs orphan services. user-guide README "Efficient data loading and caching" replaced with real Server-side processing. Filed as #648; rolls under #632.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.194</strong> — <?php _e('Docs hygiene: docs/RELEASE.md corrected (2 drifts in the release-process runbook). Stale test count "(17 tests)" for checksum auditor — actual is 41; replaced with a self-verification grep. Lint scope description listed dirs that summed to 138 while claiming 139 — missing bin/release-checksum-audit.php; rewritten to match the real find scope (entire tree minus vendor/build/node_modules/tests/.tmp-*). Filed as #647; rolls under #632.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.193</strong> — <?php _e('Docs hygiene: docs/api/README.md Shortcode quick reference corrected. Listed [gravity_table id="1" per_page="25" sort="date" order="desc"] and [gravity_table id="1" columns="1,3,5,7"] — only id / allowed_user_roles / user_role_filter are registered shortcode_atts, all other keys silently dropped. The API index contradicted its own well-maintained shortcodes.md detailed page. Fixed. Filed as #646; rolls under #632.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.192</strong> — <?php _e('Docs hygiene: docs/developer/README.md corrected (5 issues). PHP requirement was 7.4+ — actual is 8.0+ (would have caused first-install fatal errors). Database tables had the same double-underscore bug as installation.md (now fixed). "Rollback flow" claim removed (no such endpoint). 3 dead API doc links cleaned up. npm install instruction removed (no package.json). Custom capabilities claim corrected. Filed as #645; rolls under #632.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.191</strong> — <?php _e('Docs hygiene: docs/user-guide/editing.md rewritten. Last User Guide chapter in the 5-chapter sweep. Editing is the headline Pro feature, so accuracy here matters most for the convert-to-Pro path. Real features kept (click-to-edit gated by Settings + role allow-list, per-column Disable Editing, Ctrl+Z/Y in-session undo, inline file upload editing, per-entry history modal, bulk delete/edit/export with straight overwrite). Fictional features dropped: per-field role permissions, Custom Options in select editing, Bulk Edit Conditional Updates / Preview, Revision Management Compare/Restore/Change Notes, Audit Reports, Workflow Integration (Approval/Notifications/Status/Assignment), Data Import bulk-update, Modal Editing / Offline / Sync mobile, Time-based / IP-based / Backup-Before-Edit permissions. Filed as #644; rolls under #632.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.190</strong> — <?php _e('Docs hygiene: docs/user-guide/frontend.md rewritten across many sections. ~20 visitor-facing features were documented that don\'t ship today: text-filter Starts With/Ends With operators, relative-date presets, Save Filter Sets, lightbox image view, click actions / tooltips / context menus, swipe navigation, full-screen mobile search, progressive loading, client-side caching, lazy images, auto-refresh, change notifications, column visibility/width toggles, page-size/sort memory, view modes, display density, arrow-key navigation, Page Up/Down, Home/End, Ctrl+A, Ctrl+F. Each claim verified against frontend.js / admin allowlist / services directory. Each fictional bullet either dropped, replaced with the real shipping equivalent, or pointed at the relevant roadmap ticket. Filed as #643; rolls under #632.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.189</strong> — <?php _e('Docs hygiene: docs/user-guide/table-builder.md rewritten (8 sections). Highest-density fiction zone in the audit. Settings Tabs replaced with real four-section scrolling structure; Display Settings reduced from ~13 fictional toggles to real settings; View/Edit Permissions collapsed to real two-surface model; Custom CSS sanitizer attribution corrected (orphan service); Save Options reduced from Draft/Publish/Preview to single Save Table; shortcode attribute list corrected to actual three accepted attrs; performance bullets grounded; Show Export Button removed. Filed as #642; rolls under #632.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.188</strong> — <?php _e('Docs hygiene: docs/troubleshooting.md corrected (6 issues). Removed Permissions-tab references, fixed Frontend Editing per-table → global, fixed Disable Editing polarity, dropped fictional Caching/Lazy loading/Auto-refresh recommendations, rewrote Mobile section to match real per-column controls, replaced invented export-permissions checklist with real Settings → Bulk Actions gating, replaced "Table Settings > Advanced > Debug Mode" with real WP_DEBUG + Debug Logs submenu pointer. Filed as #641; rolls under #632.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.187</strong> — <?php _e('Docs hygiene: docs/faq.md corrected (3 issues). Caching Q was entirely fictional UI guidance — no plugin-level cache exists; rewritten to recommend external object/page cache. Edit-permissions Q overstated the per-column Disable Editing checkbox as a per-role permission system; rewritten to point at the two real surfaces (Settings page + table builder). "Hide specific columns from certain users" removed (empty grep). Filed as #640; rolls under #632.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.186</strong> — <?php _e('Docs hygiene: docs/installation.md corrected (4 issues). Wrong plugin name in Method 2 ("Gravity Tables" → "Advanced Data Tables for Gravity Forms"); broken DB-prefix syntax ({prefix}_X → {prefix}X to avoid double-underscore); orphan-service rationale in File Permissions removed; Initial Configuration steps tightened to match real Settings-page contents (no global default-sort exists). Filed as #639; rolls under #632.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.185</strong> — <?php _e('Docs hygiene: docs/quick-start.md Step 3 rewritten. The Getting Started Guide described a tabbed table builder with General Settings / Permissions / Advanced tabs — the real builder is a single scrolling page with four numbered sections. Removed fictional "Default Sort" / "Export Options" / "Loading Options" / "Cache Settings" claims. Filed as #638; rolls under #632.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.184</strong> — <?php _e('Docs hygiene: License & Account admin view (admin/views/license.php) corrected. Removed an unverified "lifetime access" promise from the Purchase card (contradicted by the file\'s own conditional "Expires" row), and rewrote the activation form\'s description to match the real flow (key validates inline; redirect goes to plugin admin, not Freemius). Filed as #637, rolls under #632.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.183</strong> — <?php _e('Docs hygiene: GRAVITY_TABLES_INTRO.md corrected (4 issues including a wrong "License: MIT" claim — real is GPL-2.0-or-later, same family as v4.7.152\'s docs/README.md fix). Bulk action list, export formats, Pro plan list all corrected to match shipping reality.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.182</strong> — <?php _e('Docs hygiene: "Earlier 4.7.x Highlights" feature catalog rewritten. The 12-bullet list listed many orphan-with-test-contract features as if they shipped (Find & Replace, multi-sort, external sources, AI tools, etc.). Replaced with 14 verified-shipping bullets + an honest closing paragraph naming the orphan services. Tracked under #632.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.181</strong> — <?php _e('In-plugin docs hygiene (self): "What\'s New" list had grown to 72 entries — same problem as v4.7.122. Consolidated v4.7.140-v4.7.155 (16 entries from the docs-hygiene umbrella\'s first half) into a single roll-up. Recent entries preserved.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.180</strong> — <?php _e('Two more table-builder UI honesty fixes in Interactive Features. "Show selection checkboxes" was disabled with "feature in development" — but show_selection IS implemented in templates/table.php; enabled it. "PDF export button" actually downloads .html (not .pdf); renamed to "Printable HTML download" and explained the print-to-PDF flow. Filed as #636.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.179</strong> — <?php _e('Branding consistency fix: All Tables H1 ("Gravity Tables") and Documentation H1 ("Gravity Tables Documentation") didn\'t match the rebranded sidebar menu ("Advanced Tables"). Both updated to "Advanced Tables" / "Advanced Tables Documentation". Settings heading was already consistent.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.178</strong> — <?php _e('Live-admin UI honesty fix #2: the table builder Bulk Actions feature group had three disabled "Feature in development" checkboxes for delete/export/edit — but all three ship (verified in v4.7.154). Replaced the misleading checkboxes with a paragraph pointing at the real Settings → Bulk Actions toggle. Filed as #635 for the per-table opt-in wire-up.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.177</strong> — <?php _e('Live-admin UI honesty fix: Settings → CSS Framework dropdown\'s description in admin/views/settings.php was promising that Default/Minimal/None each have a visible effect. v4.7.161 admitted this in the docs viewer; this iteration extends the admission to the live setting description so customers see the truth before saving.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.176</strong> — <?php _e('Docs hygiene + readme.txt Installation/FAQ/Screenshots sections corrected (continuing from v4.7.175). Wrong menu/submenu in After Installation steps, wrong PDF claim in the export FAQ + Screenshot 6, duplicate "Frontend Editing" screenshots removed, and the trial FAQ rewritten with the verified Pro-feature list.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.175</strong> — <?php _e('Docs hygiene + readme.txt Description / Easy Setup corrected. This is the WordPress.org-facing copy. Wrong PDF export claim, wrong menu name ("Gravity Tables" → "Advanced Tables"), and wrong submenu label ("Create New Table" → "Create New") all fixed. v4.7.148 swept docs/ but missed readme.txt.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.174</strong> — <?php _e('Docs hygiene + Permission Troubleshooting step 2 corrected. "Open the table → General tab → Access Control" was misleading — Access Control lives in the main table builder under "User Permissions", not in a field-level tab. Customers following step 2 would have looked inside a column field modal where the panel doesn\'t exist. Steps 4-5 also clarified.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.173</strong> — <?php _e('Docs hygiene + Conditional Formatting section corrected. Three issues caught: tab-access path was vague; conditions list claimed between/regex (not shipped) and missed not_equals/is_empty/is_not_empty; styling options claimed border (not shipped) and missed italic/css_class. Doc now matches TC_Conditional_Formatting_Service.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.172</strong> — <?php _e('Docs hygiene + Customization section split. Working items kept with citations: Width/Wrap Mode/Typography/Sticky Headers/Mobile card view/Inline Custom CSS/Conditional Formatting/Developer Hooks. 11 orphan items consolidated. Customization Tips pruned of dead-CSS-framework references. Custom CSS attribution corrected (it\'s TC_Admin not TC_Custom_CSS_Writer).', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.171</strong> — <?php _e('Docs hygiene + Troubleshooting section pruned of advice for not-yet-wired-up features. URL-filter / drilldown / pivot bullets that referenced features flagged as orphan in v4.7.165/166/169 were removed. Subsection renamed from "Drilldown / Detail Row / Pivot Issues" to "Detail Row Issues" since detail rows is the only one that ships.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.170</strong> — <?php _e('Docs hygiene + Cell Types section split. Real (with call-site citations): Date/Time, Lookup Field, Conditional Format, Typography. Not wired up (admitted): Star Rating, Formula, per-column Border, per-column Vertical-Align. Customers reading the cell-types catalog now know exactly what works and what doesn\'t.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.169</strong> — <?php _e('Docs hygiene + URL-Driven Filters flagged as not-wired-up + correction of v4.7.146/v4.7.166 misclaims pointing at it as a "working alternative". TC_URL_Filter_Service exists but has no production caller. Same lesson as v4.7.155 → v4.7.156: when unwinding a fiction, verify the alternative before pointing at it.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.168</strong> — <?php _e('Docs hygiene + Find & Replace flagged as not-wired-up; Fuzzy Search confirmed real with call-site citations. Section renamed from "Find & Replace, Bulk Tools" to "Search Tools" since fuzzy search is the only working feature. TC_Fuzzy_Search_Service::is_enabled/build_meta_where called from class-tc-ajax.php; TC_Find_Replace_Service has zero non-service-file refs.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.167</strong> — <?php _e('Docs hygiene + Row Display Controls section corrected. Of seven listed features (Row Grouping, Sticky Rows, Row Height, Wrap Mode, Cell Merge, Row Expiry, Collapsible Sections), only Wrap Mode is actually wired into the rendering path. Other six are orphan-with-test-contract.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.166</strong> — <?php _e('Docs hygiene + Click-to-Filter Drilldown flagged as not-wired-up. The Drilldown section above used to describe click-any-cell filtering with chips. Reality: TC_Drilldown_Filter_Service is implemented but no JS handler / AJAX endpoint / Advanced-tab UI consumes it. Pointed customers at the related URL-Driven Filters feature (which actually ships).', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.165</strong> — <?php _e('Docs hygiene + Pivot Tables flagged as not-wired-up. The Pivot Tables section above used to describe a 6-step Advanced-tab configuration workflow. Reality: TC_Pivot_Service is a complete implementation with #562 test contract but no UI exposes it (zero "pivot" matches in admin/views/table-builder.php). Same orphan-with-test-contract pattern.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.164</strong> — <?php _e('Docs hygiene + External Data Sources flagged as not-wired-up. The section above used to describe JSON/Airtable/Notion as configurable through the table builder. Reality: the three orphan services (JSON_Source, Airtable_Field_Mapper, API_Key_Service) exist with test contracts but have no UI wire-up. Same pattern as v4.7.155/156/158/162/163.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.163</strong> — <?php _e('Docs hygiene + AI Tools flagged as not-wired-up. The AI Tools section above used to describe Cleanup Suggester + Table Summarizer as available features with a "Settings → API Keys" pointer. Reality: both services exist as runnable PHP but neither has any UI/AJAX wire-up, and the API Keys panel doesn\'t exist. Same orphan-with-test-contract pattern as multi-sort/skeleton/bulk-migration-bundle.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.162</strong> — <?php _e('Docs hygiene (in-plugin Troubleshooting): two inactionable advice lines fixed — "Enable skeleton loader" (no toggle exists; service is orphan-with-test-contract) and "Enable Debug Settings (left menu)" (wrong menu name; real is "Debug Logs", and it\'s a viewer not a toggle).', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.161</strong> — <?php _e('Docs hygiene + dead-setting flag: the CSS Framework Options section right above used to describe what Default/Minimal/None each "do". Reality: nothing in source reads the saved value, so the dropdown is currently inert. Doc admits this and recommends a wp_dequeue_style workaround until the wire-up lands.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.160</strong> — <?php _e('Docs hygiene (in-plugin, cont): caught a second fictional "Bulk Migration Bundle" reference — v4.7.158 only removed it from the Bulk Actions list; this iteration removes the duplicate claim from the Find & Replace section. Also added the missing Excel (.xlsx) export documentation that customers needed and the CSV-streaming detail.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.159</strong> — <?php _e('Docs hygiene (in-plugin, cont): the Sorting section right above used to claim shift-click multi-column sort works. The service is built but not wired into AJAX/template path. Renamed to Click-to-Sort and added an explicit gap-note. Same fix applied to docs/user-guide/frontend.md in v4.7.155.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.158</strong> — <?php _e('Docs hygiene (in-plugin): the Bulk Actions section right above used to list 4 options including "Bulk Migration Bundle" — only 3 are real. Removed the misleading line, clarified Pro-plan gating, and added "no append/prepend modes" so customers don\'t expect more than the handler does.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.157</strong> — <?php _e('Docs hygiene: corrected docs/user-guide/table-builder.md. Caught fictional UI controls — Loading Behavior section (Auto-load/Lazy Load/Cache Duration), Advanced Permissions (IP Restrictions, Conditional Access), Advanced Settings (Performance/Technical Options/Debug Options) — all settings that don\'t exist in the builder UI. Replaced with what\'s real.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.156</strong> — <?php _e('Bootstrap hygiene + correction: applied the v4.7.140 lesson to the multi-sort service flagged in v4.7.155. v4.7.155 claimed "no test contract (verified)" — wrong, test #148 does assert the include must be present. Added an explanatory comment above the require_once. Lesson re-logged.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.140 – 4.7.155</strong> — <?php _e('Docs-hygiene umbrella (issue #632) — first half: bootstrap hygiene (orphan require_once cleanup in v4.7.140-v4.7.141), stale planning-doc deletions (v4.7.142-v4.7.143 — RANGE_FILTER_SPECIFICATION.md, v4.1.30-TEST-PLAN.md), API/hooks/shortcodes doc rewrites (v4.7.144-v4.7.146 — 27 of 28 hooks were fictional; only 1 of 5 shortcodes documented), installation/troubleshooting/menu-name + DB-table corrections (v4.7.147-v4.7.150), RELEASE.md / docs/README.md license / plugin-root README perf-claim corrections (v4.7.151-v4.7.153), Bulk Operations + frontend.md export/multi-sort corrections (v4.7.154-v4.7.155). See readme.txt or docs/CHANGELOG.md for per-version detail.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.139</strong> — <?php _e('Tools: tools/test-all.sh now falls back to printing the last stderr lines when a test fatals before runner.php loads. Surfaces the cause on first build run.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.138</strong> — <?php _e('Build pipeline: final orphan — assets/css/frontend-minimal.css (3.4KB, never enqueued) excluded. Every shipping JS/CSS now corresponds to code the plugin executes.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.137</strong> — <?php _e('Build pipeline: two more orphans excluded — assets/js/frontend-vanilla.js (27KB) + assets/js/performance-test.js (12KB). Together with v4.7.136 ~75KB of dev-only code no longer ships.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.136</strong> — <?php _e('Build pipeline: stopped shipping two dev-only artifacts (admin/js/admin-vanilla.js, admin/js/CONVERSION_STATUS.md) to customers. Every install ~36KB lighter.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.135</strong> — <?php _e('Docs hygiene final-pass: GRAVITY_TABLES_INTRO.md + docs/business-plan.md updated to reflect the v4.7.x reality (Phase 1 stabilization done, several Phase 3 features shipped).', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.134</strong> — <?php _e('Cleanup: stripped 8 stale @version docblocks (stuck at 4.1.x for 25+ releases) and rewrote ARCHITECTURE.md "Version Locations" table to point at tools/bump-version.sh.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.133</strong> — <?php _e('Docs hygiene: removed the redundant "Recent Updates" tail (9 more v4.1.x blurbs) from plugin-root README. README is now 76 lines, down from 198 at start of day.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.132</strong> — <?php _e('Docs hygiene: pruned 12 stale per-release "What\'s New" sections from the plugin-root README (v4.1.7 down to v2.0.1). Replaced with a one-paragraph summary; ~140 lines lighter.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.131</strong> — <?php _e('Docs hygiene: replaced the stale plugin-root README.md "What\'s New in v4.1.8" + version pin (30+ releases stale) with self-deferring pointers to readme.txt and the plugin header.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.130</strong> — <?php _e('Docs hygiene: pruned 3 phantom-page links from quick-start.md\'s Next Steps. Now points only at real chapters + the in-plugin help.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.129</strong> — <?php _e('Docs correction: installation.md + faq.md stated "PHP 7.4 or higher" but the plugin requires PHP 8.0. Real user-facing fix — prevents wasted install attempts on out-of-spec PHP.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.128</strong> — <?php _e('Docs hygiene: top-level docs/README.md had a stale "Current Version: 4.1.8" pinned. Replaced with a pointer to the plugin header so it never drifts again.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.127</strong> — <?php _e('Docs hygiene continued: pruned the same phantom-link ToC pattern from docs/api/README.md (5/6 missing) and docs/user-guide/README.md (6/9 missing).', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.126</strong> — <?php _e('Docs hygiene: pruned docs/developer/README.md — it linked to 6 phantom chapter files. Replaced with a tooling quick-reference and pointers to the real docs.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.125</strong> — <?php _e('Tooling refactor: tools/test-all.sh is the new ad-hoc test runner (309 files; pairs with tools/lint-php.sh). build-release.sh delegates to it.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.124</strong> — <?php _e('Build pipeline now runs the full test suite as a pre-build hook. Caught + fixed 5 latent tests that were silently aborting (#188, #189, #190, #191, #192) — all green now.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.123</strong> — <?php _e('CI workflow staged for one-mv install (#144/#145): tools/ci.yml.example holds the prepared GitHub Actions workflow. Move into .github/workflows/ once the workflow OAuth scope is granted.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.122</strong> — <?php _e('In-plugin docs UX: pruned the "What\'s New" list — was 28+ entries, now collapses v4.7.100–v4.7.115 into a single summary line so the help screen is readable again.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.121</strong> — <?php _e('Repo hygiene: added .tmp-* to .gitignore + .distignore, and rewrote docs/RELEASE.md to reflect the current pipeline (bump → changelog → build → GitHub → Freemius).', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.120</strong> — <?php _e('Tooling polish: tools/bump-version.sh now prints the three-changelog-source reminder so future bumps don\'t silently drift the in-plugin "What\'s New" view behind the readme.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.119</strong> — <?php _e('Build pipeline (#144/#145 partial): tools/build-release.sh now runs tools/lint-php.sh as a pre-build hook so a parse error or deprecated WP call can never end up in a published ZIP.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.118</strong> — <?php _e('Developer tooling (#144/#145 partial): tools/lint-php.sh runs php -l over 139 source files plus a deprecated-WP-function audit. Drops into a future CI workflow once the workflow OAuth scope is granted.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.117</strong> — <?php _e('Cleanup (#544 slice 2): removed the JS sticky-header helpers re-added in v4.7.111/v4.7.114. CSS-native position:sticky on th has handled this since v4.7.71 — no JS width-sync needed.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.116</strong> — <?php _e('Refinement (#535, #433): undo-handler skip-guard switched from a jQuery :not() selector to the browser-native active.isContentEditable property — simpler and respects ancestor inheritance.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.100 – 4.7.115</strong> — <?php _e('A burst of test-suite hygiene + small bug fixes (security: nonce-before-diagnostics in save_table; Elementor widget did_action guard; multi-row sticky-header alignment; doc-comment rephrasings to clear stale static scanners; test-harness polyfills). See readme.txt or docs/CHANGELOG.md for per-version detail.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.55</strong> — <?php _e('Release-hygiene chore: readme Stable tag synced with plugin header. No code changes.', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.54</strong> — <?php _e('Vendored-dependency CVE audit (PhpSpreadsheet, Freemius SDK pinned and tracked).', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.53</strong> — <?php _e('Click-to-Filter Drilldown service (per-column opt-in, URL-persisted).', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.52</strong> — <?php _e('Row Link URL builder (entire-row click-through with field-placeholder URL templates).', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.51</strong> — <?php _e('Pivot Tables (group/aggregate/reshape entries without SQL).', 'tc-data-tables'); ?></li>
                    <li><strong>4.7.50</strong> — <?php _e('Pagination service refactor + custom pagination labels.', 'tc-data-tables'); ?></li>
                </ul>
                <p>
                    <button type="button" class="button gt-recent-versions-toggle"
                            data-label-show="<?php esc_attr_e('Show full version history', 'tc-data-tables'); ?>"
                            data-label-hide="<?php esc_attr_e('Show recent only', 'tc-data-tables'); ?>">
                        <?php esc_html_e('Show full version history', 'tc-data-tables'); ?>
                    </button>
                </p>
                <style>
                    .gt-recent-versions[data-gt-recent-collapsed="1"] li:nth-child(n+6) {
                        display: none;
                    }
                </style>
                <script>
                (function () {
                    var btn  = document.querySelector('.gt-recent-versions-toggle');
                    var list = document.querySelector('.gt-recent-versions');
                    if (!btn || !list) return;
                    btn.addEventListener('click', function () {
                        var collapsed = list.getAttribute('data-gt-recent-collapsed') === '1';
                        if (collapsed) {
                            list.removeAttribute('data-gt-recent-collapsed');
                            btn.textContent = btn.getAttribute('data-label-hide');
                        } else {
                            list.setAttribute('data-gt-recent-collapsed', '1');
                            btn.textContent = btn.getAttribute('data-label-show');
                        }
                    });
                }());
                </script>

                <h3><?php _e('Earlier 4.7.x Highlights — features that ship today', 'tc-data-tables'); ?></h3>
                <ul>
                    <li><?php _e('Detail rows (eye-icon entry preview popup)', 'tc-data-tables'); ?></li>
                    <li><?php _e('Per-table default sort (single-column click-to-sort)', 'tc-data-tables'); ?></li>
                    <li><?php _e('Pagination service (per-table per-page setting)', 'tc-data-tables'); ?></li>
                    <li><?php _e('Per-column typography (font family, size, weight, line height)', 'tc-data-tables'); ?></li>
                    <li><?php _e('Per-column wrap mode (truncate, wrap, expand-on-hover)', 'tc-data-tables'); ?></li>
                    <li><?php _e('Inline custom CSS per table (sanitised + scoped to #gt-table-id)', 'tc-data-tables'); ?></li>
                    <li><?php _e('Sticky table headers via CSS-native position:sticky on th', 'tc-data-tables'); ?></li>
                    <li><?php _e('Mobile card view (auto-converts below 768px / 480px)', 'tc-data-tables'); ?></li>
                    <li><?php _e('Conditional formatting (background/text color, bold, italic, css_class)', 'tc-data-tables'); ?></li>
                    <li><?php _e('Fuzzy search (typo-tolerant global search; per-table opt-in)', 'tc-data-tables'); ?></li>
                    <li><?php _e('Lookup fields (user / post / custom-table; AJS Toolkit auto-detect)', 'tc-data-tables'); ?></li>
                    <li><?php _e('Webhook + email notifications on entry create/update/delete', 'tc-data-tables'); ?></li>
                    <li><?php _e('Bulk actions: delete, edit, export (Pro plan)', 'tc-data-tables'); ?></li>
                    <li><?php _e('CSV (streamed) + Excel (.xlsx via PhpSpreadsheet) export', 'tc-data-tables'); ?></li>
                </ul>
                <p><?php _e('Earlier versions of this list also included Find & Replace, Multi-column sort, External data sources (JSON / Airtable / Notion), AI tools, Pivot tables, Star rating cell, Drilldown, per-column Border / Vertical-Align, Sticky rows, Row grouping, Cell merge, Row expiry, Skeleton loader, Scroll buttons, Toolbar visibility, Print settings, Custom export filename, RTL support — those are all still implemented as service classes in includes/services/ but currently not wired into the UI/AJAX/template path. They\'re tracked under the docs-hygiene umbrella (#632) for proper wire-up. Today\'s shipping feature set is the list above.', 'tc-data-tables'); ?></p>

                <h3><?php _e('Legacy (3.x and earlier)', 'tc-data-tables'); ?></h3>
                <p><?php _e('Driver-role permission fix, enhanced permission system, multi-select dropdown filters, spreadsheet-like inline editing, lookup-field rewrite with three-tier fallback, date-format consistency, user-lookup-field support. See CHANGELOG.md for full history.', 'tc-data-tables'); ?></p>
            </div>
        </div>
    </div>
</div>
